import
authorAlan Knowles <alan@roojs.com>
Sat, 22 Jan 2011 03:15:31 +0000 (11:15 +0800)
committerAlan Knowles <alan@roojs.com>
Sat, 22 Jan 2011 03:15:31 +0000 (11:15 +0800)
355 files changed:
.hgignore [new file with mode: 0644]
LICENSE [new file with mode: 0644]
README [new file with mode: 0644]
bin/acl-check.php [new file with mode: 0644]
bin/codeshell [new file with mode: 0755]
bin/data-move.php [new file with mode: 0644]
bin/git-commit-hook [new file with mode: 0755]
bin/hg-commit-hook [new file with mode: 0755]
bin/import-trac.php [new file with mode: 0644]
bin/init.php [new file with mode: 0644]
bin/make-authorized-keys.php [new file with mode: 0644]
bin/modify.php [new file with mode: 0644]
bin/schema-tool.php [new file with mode: 0644]
bin/send-notifications.php [new file with mode: 0644]
bin/setup [new file with mode: 0755]
bin/setup.bat [new file with mode: 0644]
bin/solr-schema.xml [new file with mode: 0644]
bin/svn-commit-hook [new file with mode: 0755]
bin/update-search-index.php [new file with mode: 0644]
config.ini.sample [new file with mode: 0644]
defaults/help/ConfigIni [new file with mode: 0644]
defaults/help/Install [new file with mode: 0644]
defaults/help/Introduction [new file with mode: 0644]
defaults/help/Links [new file with mode: 0644]
defaults/help/Plugins [new file with mode: 0644]
defaults/help/SSH [new file with mode: 0644]
defaults/help/Searching [new file with mode: 0644]
defaults/help/TicketQuery [new file with mode: 0644]
defaults/help/TracReports [new file with mode: 0644]
defaults/help/WikiFormatting [new file with mode: 0644]
defaults/help/bin/Init [new file with mode: 0644]
defaults/help/bin/Modify [new file with mode: 0644]
defaults/help/plugin/AuthHTTP [new file with mode: 0644]
defaults/help/plugin/CommitCheckNoEmpty [new file with mode: 0644]
defaults/help/plugin/CommitCheckTimeRef [new file with mode: 0644]
defaults/help/plugin/OpenID [new file with mode: 0644]
defaults/help/plugin/Recaptcha [new file with mode: 0644]
defaults/reports/ActiveTickets [new file with mode: 0644]
defaults/reports/Mine [new file with mode: 0644]
defaults/wiki/Today [new file with mode: 0644]
defaults/wiki/WikiStart [new file with mode: 0644]
inc/UUID.php [new file with mode: 0644]
inc/acl.php [new file with mode: 0644]
inc/attachment.php [new file with mode: 0644]
inc/auth.php [new file with mode: 0644]
inc/auth/http.php [new file with mode: 0644]
inc/auth/openid.php [new file with mode: 0644]
inc/cache.php [new file with mode: 0644]
inc/captcha.php [new file with mode: 0644]
inc/changeset.php [new file with mode: 0644]
inc/commit-hook.php [new file with mode: 0644]
inc/common.php [new file with mode: 0644]
inc/configuration.php [new file with mode: 0644]
inc/customfield.php [new file with mode: 0644]
inc/database.php [new file with mode: 0644]
inc/hyperlight/cpp.php [new file with mode: 0644]
inc/hyperlight/csharp.php [new file with mode: 0644]
inc/hyperlight/css.php [new file with mode: 0644]
inc/hyperlight/hyperlight.php [new file with mode: 0644]
inc/hyperlight/iphp.php [new file with mode: 0644]
inc/hyperlight/javascript.php [new file with mode: 0644]
inc/hyperlight/perl.php [new file with mode: 0644]
inc/hyperlight/php.php [new file with mode: 0644]
inc/hyperlight/preg_helper.php [new file with mode: 0644]
inc/hyperlight/python.php [new file with mode: 0644]
inc/hyperlight/shell.php [new file with mode: 0644]
inc/hyperlight/vb.php [new file with mode: 0644]
inc/hyperlight/vibrant-ink.css [new file with mode: 0644]
inc/hyperlight/wezterm.css [new file with mode: 0644]
inc/hyperlight/wiki.php [new file with mode: 0644]
inc/hyperlight/xml.php [new file with mode: 0644]
inc/hyperlight/zenburn.css [new file with mode: 0644]
inc/issue.php [new file with mode: 0644]
inc/keywords.php [new file with mode: 0644]
inc/lib/Auth/COPYING [new file with mode: 0644]
inc/lib/Auth/OpenID.php [new file with mode: 0644]
inc/lib/Auth/OpenID/AX.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Association.php [new file with mode: 0644]
inc/lib/Auth/OpenID/BigMath.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Consumer.php [new file with mode: 0644]
inc/lib/Auth/OpenID/CryptUtil.php [new file with mode: 0644]
inc/lib/Auth/OpenID/DatabaseConnection.php [new file with mode: 0644]
inc/lib/Auth/OpenID/DiffieHellman.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Discover.php [new file with mode: 0644]
inc/lib/Auth/OpenID/DumbStore.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Extension.php [new file with mode: 0644]
inc/lib/Auth/OpenID/FileStore.php [new file with mode: 0644]
inc/lib/Auth/OpenID/HMAC.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Interface.php [new file with mode: 0644]
inc/lib/Auth/OpenID/KVForm.php [new file with mode: 0644]
inc/lib/Auth/OpenID/MemcachedStore.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Message.php [new file with mode: 0644]
inc/lib/Auth/OpenID/MySQLStore.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Nonce.php [new file with mode: 0644]
inc/lib/Auth/OpenID/PAPE.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Parse.php [new file with mode: 0644]
inc/lib/Auth/OpenID/PostgreSQLStore.php [new file with mode: 0644]
inc/lib/Auth/OpenID/SQLStore.php [new file with mode: 0644]
inc/lib/Auth/OpenID/SQLiteStore.php [new file with mode: 0644]
inc/lib/Auth/OpenID/SReg.php [new file with mode: 0644]
inc/lib/Auth/OpenID/Server.php [new file with mode: 0644]
inc/lib/Auth/OpenID/ServerRequest.php [new file with mode: 0644]
inc/lib/Auth/OpenID/TrustRoot.php [new file with mode: 0644]
inc/lib/Auth/OpenID/URINorm.php [new file with mode: 0644]
inc/lib/Auth/Yadis/HTTPFetcher.php [new file with mode: 0644]
inc/lib/Auth/Yadis/Manager.php [new file with mode: 0644]
inc/lib/Auth/Yadis/Misc.php [new file with mode: 0644]
inc/lib/Auth/Yadis/ParanoidHTTPFetcher.php [new file with mode: 0644]
inc/lib/Auth/Yadis/ParseHTML.php [new file with mode: 0644]
inc/lib/Auth/Yadis/PlainHTTPFetcher.php [new file with mode: 0644]
inc/lib/Auth/Yadis/XML.php [new file with mode: 0644]
inc/lib/Auth/Yadis/XRDS.php [new file with mode: 0644]
inc/lib/Auth/Yadis/XRI.php [new file with mode: 0644]
inc/lib/Auth/Yadis/XRIRes.php [new file with mode: 0644]
inc/lib/Auth/Yadis/Yadis.php [new file with mode: 0644]
inc/lib/Zend/Exception.php [new file with mode: 0644]
inc/lib/Zend/Search/Exception.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/Token.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/TokenFilter.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/LowerCase.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Document.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Document/Docx.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Document/Exception.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Document/Html.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Document/OpenXml.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Document/Pptx.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Document/Xlsx.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Exception.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/FSM.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/FSMAction.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Field.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/DictionaryLoader.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/DocsFilter.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/FieldInfo.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/SegmentInfo.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/SegmentMerger.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/SegmentWriter.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/Term.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/TermInfo.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/TermsPriorityQueue.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/TermsStream/Interface.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Index/Writer.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Interface.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/LockManager.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/MultiSearcher.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/PriorityQueue.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Proxy.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Highlighter/Default.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Highlighter/Interface.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Boolean.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Empty.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Fuzzy.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Insignificant.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/MultiTerm.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Phrase.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Fuzzy.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Term.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Range.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Term.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Query/Wildcard.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryEntry.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryEntry/Phrase.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryEntry/Subquery.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryEntry/Term.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryHit.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryLexer.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryParser.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryParserContext.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryParserException.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/QueryToken.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Similarity.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Similarity/Default.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Weight.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Weight/Boolean.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Weight/Empty.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Weight/MultiTerm.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Weight/Phrase.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Search/Weight/Term.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Storage/Directory.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Storage/Directory/Filesystem.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Storage/File.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Storage/File/Filesystem.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/Storage/File/Memory.php [new file with mode: 0644]
inc/lib/Zend/Search/Lucene/TermStreamsPriorityQueue.php [new file with mode: 0644]
inc/milestone.php [new file with mode: 0644]
inc/report.php [new file with mode: 0644]
inc/scm.php [new file with mode: 0644]
inc/scm/git.php [new file with mode: 0644]
inc/scm/hg.php [new file with mode: 0644]
inc/scm/svn.php [new file with mode: 0644]
inc/search.php [new file with mode: 0644]
inc/search/lucene.php [new file with mode: 0644]
inc/search/solr.php [new file with mode: 0644]
inc/snippet.php [new file with mode: 0644]
inc/syntax.php [new file with mode: 0644]
inc/timeline.php [new file with mode: 0644]
inc/watch.php [new file with mode: 0644]
inc/web.php [new file with mode: 0644]
inc/wiki-item.php [new file with mode: 0644]
inc/wiki.php [new file with mode: 0644]
schema/0.xml [new file with mode: 0644]
schema/1.php [new file with mode: 0644]
schema/1.xml [new file with mode: 0644]
schema/2.xml [new file with mode: 0644]
schema/3.xml [new file with mode: 0644]
schema/4-pre.php [new file with mode: 0644]
schema/4.xml [new file with mode: 0644]
schema/5.xml [new file with mode: 0644]
schema/6.php [new file with mode: 0644]
schema/6.xml [new file with mode: 0644]
schema/7.xml [new file with mode: 0644]
schema/8.xml [new file with mode: 0644]
web/.htaccess [new file with mode: 0644]
web/admin/auth.php [new file with mode: 0644]
web/admin/component.php [new file with mode: 0644]
web/admin/customfield.php [new file with mode: 0644]
web/admin/deleterepo.php [new file with mode: 0644]
web/admin/enum.php [new file with mode: 0644]
web/admin/forkrepo.php [new file with mode: 0644]
web/admin/group.php [new file with mode: 0644]
web/admin/importcsv.php [new file with mode: 0644]
web/admin/index.php [new file with mode: 0644]
web/admin/logs.php [new file with mode: 0644]
web/admin/project.php [new file with mode: 0644]
web/admin/repo.php [new file with mode: 0644]
web/admin/user.php [new file with mode: 0644]
web/admin/watch.php [new file with mode: 0644]
web/attachment.php [new file with mode: 0644]
web/avatar.php [new file with mode: 0644]
web/browse.php [new file with mode: 0644]
web/changeset.php [new file with mode: 0644]
web/css.php [new file with mode: 0644]
web/css/markitup/bold.png [new file with mode: 0755]
web/css/markitup/code.png [new file with mode: 0755]
web/css/markitup/h1.png [new file with mode: 0755]
web/css/markitup/h2.png [new file with mode: 0755]
web/css/markitup/h3.png [new file with mode: 0755]
web/css/markitup/h4.png [new file with mode: 0755]
web/css/markitup/h5.png [new file with mode: 0755]
web/css/markitup/h6.png [new file with mode: 0755]
web/css/markitup/handle.png [new file with mode: 0755]
web/css/markitup/italic.png [new file with mode: 0755]
web/css/markitup/link.png [new file with mode: 0755]
web/css/markitup/list-bullet.png [new file with mode: 0755]
web/css/markitup/list-numeric.png [new file with mode: 0755]
web/css/markitup/markitup-simple.css [new file with mode: 0755]
web/css/markitup/menu.png [new file with mode: 0755]
web/css/markitup/picture.png [new file with mode: 0755]
web/css/markitup/preview.png [new file with mode: 0755]
web/css/markitup/quotes.png [new file with mode: 0755]
web/css/markitup/stroke.png [new file with mode: 0755]
web/css/markitup/submenu.png [new file with mode: 0755]
web/css/markitup/url.png [new file with mode: 0755]
web/css/markitup/wiki.css [new file with mode: 0644]
web/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png [new file with mode: 0755]
web/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png [new file with mode: 0755]
web/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png [new file with mode: 0755]
web/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png [new file with mode: 0755]
web/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png [new file with mode: 0755]
web/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png [new file with mode: 0755]
web/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png [new file with mode: 0755]
web/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png [new file with mode: 0755]
web/css/smoothness/images/ui-icons_222222_256x240.png [new file with mode: 0755]
web/css/smoothness/images/ui-icons_2e83ff_256x240.png [new file with mode: 0755]
web/css/smoothness/images/ui-icons_454545_256x240.png [new file with mode: 0755]
web/css/smoothness/images/ui-icons_888888_256x240.png [new file with mode: 0755]
web/css/smoothness/images/ui-icons_cd0a0a_256x240.png [new file with mode: 0755]
web/css/smoothness/jquery-ui-1.7.2.custom.css [new file with mode: 0755]
web/ext.php [new file with mode: 0644]
web/file.php [new file with mode: 0644]
web/help.php [new file with mode: 0644]
web/images/changeset.png [new file with mode: 0644]
web/images/closedticket.png [new file with mode: 0644]
web/images/default_avatar.png [new file with mode: 0755]
web/images/editedticket.png [new file with mode: 0644]
web/images/feed-icon-16x16.png [new file with mode: 0644]
web/images/file.png [new file with mode: 0644]
web/images/filedeny.png [new file with mode: 0644]
web/images/folder.png [new file with mode: 0644]
web/images/folderdeny.png [new file with mode: 0644]
web/images/gradient-footer.png [new file with mode: 0644]
web/images/gradient-header.png [new file with mode: 0644]
web/images/logo_openid.png [new file with mode: 0644]
web/images/milestone.png [new file with mode: 0644]
web/images/newticket.png [new file with mode: 0644]
web/images/parent.png [new file with mode: 0644]
web/images/sort/asc.gif [new file with mode: 0755]
web/images/sort/bg.gif [new file with mode: 0755]
web/images/sort/desc.gif [new file with mode: 0755]
web/images/treeview/file.gif [new file with mode: 0644]
web/images/treeview/folder-closed.gif [new file with mode: 0644]
web/images/treeview/folder.gif [new file with mode: 0644]
web/images/treeview/minus.gif [new file with mode: 0644]
web/images/treeview/plus.gif [new file with mode: 0644]
web/images/treeview/treeview-black-line.gif [new file with mode: 0644]
web/images/treeview/treeview-black.gif [new file with mode: 0644]
web/images/treeview/treeview-default-line.gif [new file with mode: 0644]
web/images/treeview/treeview-default.gif [new file with mode: 0644]
web/images/treeview/treeview-famfamfam-line.gif [new file with mode: 0644]
web/images/treeview/treeview-famfamfam.gif [new file with mode: 0644]
web/images/treeview/treeview-gray-line.gif [new file with mode: 0644]
web/images/treeview/treeview-gray.gif [new file with mode: 0644]
web/images/treeview/treeview-red-line.gif [new file with mode: 0644]
web/images/treeview/treeview-red.gif [new file with mode: 0644]
web/images/wiki.png [new file with mode: 0644]
web/index.php [new file with mode: 0644]
web/js.php [new file with mode: 0644]
web/js/excanvas.pack.js [new file with mode: 0644]
web/js/jquery-1.4.2.min.js [new file with mode: 0644]
web/js/jquery-ui-1.8.2.custom.min.js [new file with mode: 0755]
web/js/jquery.MultiFile.pack.js [new file with mode: 0755]
web/js/jquery.asmselect.js [new file with mode: 0644]
web/js/jquery.cookie.js [new file with mode: 0644]
web/js/jquery.flot.pack.js [new file with mode: 0644]
web/js/jquery.markitup.js [new file with mode: 0755]
web/js/jquery.metadata.js [new file with mode: 0755]
web/js/jquery.tablesorter.js [new file with mode: 0644]
web/js/jquery.timeago.js [new file with mode: 0644]
web/js/jquery.treeview.js [new file with mode: 0644]
web/js/json2.js [new file with mode: 0644]
web/log.php [new file with mode: 0644]
web/markitup-preview.php [new file with mode: 0644]
web/milestone.php [new file with mode: 0644]
web/mtrack.css [new file with mode: 0644]
web/openid.php [new file with mode: 0644]
web/query.php [new file with mode: 0644]
web/report.php [new file with mode: 0644]
web/reports.php [new file with mode: 0644]
web/roadmap.php [new file with mode: 0644]
web/search.php [new file with mode: 0644]
web/snippet.php [new file with mode: 0644]
web/ticket.php [new file with mode: 0644]
web/timeline.php [new file with mode: 0644]
web/user.php [new file with mode: 0644]
web/wiki.php [new file with mode: 0644]

diff --git a/.hgignore b/.hgignore
new file mode 100644 (file)
index 0000000..1b5939e
--- /dev/null
+++ b/.hgignore
@@ -0,0 +1,7 @@
+syntax:glob
+.DS_Store
+.*.swp*
+var*
+trac-data.tar.bz2
+trac-data
+glob:config.ini
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..98b3d44
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,120 @@
+The bulk of this work is authored by Wez Furlong and is:
+Copyright (C) 2008-2010 Message Systems, Inc.
+All rights reserved.
+
+This software is derived from the "Trac" software product, which is:
+Copyright (C) 2003-2008 Edgewall Software
+All rights reserved.
+The origin of that software is http://trac.edgewall.org
+
+Portions of this software are taken from the Alexandria PHP
+library which is authored by Wez Furlong and is:
+Copyright (C) 2007, OmniTI Computer Consulting, Inc.
+The origin of that software is http://bitbucket.org/wez/alexandria
+
+The Lucene search implementation is built using software from
+the Zend Framework, which is:
+Copyright (C) 2005-2009, Zend Technologies USA, Inc.
+The origin of that software is http://framework.zend.com
+
+This software uses the jQuery javascript/CSS/UI library, which is
+Copyright (C) 2009 John Resig
+The origin of that software is http://jquery.com
+
+This software uses the markItUp! Universal MarkUp Engine, which is
+Copyright (C) 2007-2009 Jay Salvat
+The origin of that software is http://markitup.jaysalvat.com/
+
+This software uses the timeago jQuery plugin, which is
+Copyright (c) 2008-2010, Ryan McGeary (ryanonjavascript -[at]- mcgeary [*dot*] org)
+The origin of that software is http://timeago.yarp.com/
+
+This software uses the PHP OpenID library by JanRain, Inc., which is
+Copyright (C) 2005-2008 JanRain, Inc.
+The origin of that software is http://openidenabled.com/php-openid/
+
+This software uses the Hyperlight syntax highlighting library for PHP, which is
+Copyright 2008 Konrad Rudolph.
+The origin of that software is http://code.google.com/p/hyperlight/
+
+Some of the icons used in this software were taken from
+http://www.smashingmagazine.com/2009/05/20/flavour-extended-the-ultimate-icon-set-for-web-designers/
+which explicitly states that it may be used for any purpose with no
+restrictions.
+I'm including a link to the artists web site as a courtesy; thank you!
+http://www.addictedtocoffee.de/
+
+It is recommended that persons intending to redistribute the third-party
+software do so by obtaining it from the origin rather than from the software
+distribution containing this license text.
+
+All of the above software, with the exception of jQuery (and plugins),
+markItUp and PHP OpenID, are subject to the modified BSD license,
+the text of which is included immediately below:
+
+-------
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in
+   the documentation and/or other materials provided with the
+   distribution.
+3. The name of the copyright holders or contributors may not be used
+   to endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS `AS IS''
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+-------
+
+jQuery (and related plugins) and markItUp are subject to the MIT license,
+included immediately below:
+
+-------
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+-------
+
+The PHP OpenID library is subject to the Apache License version 2.0.
+The full text of that license can be found at:
+
+  http://www.apache.org/licenses/LICENSE-2.0 Apache
+
+another copy of the license text can be found in the file
+
+  inc/Auth/COPYING
+
+which is contained in this source distribution.
+
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..e2441f0
--- /dev/null
+++ b/README
@@ -0,0 +1,8 @@
+mtrack
+
+This is a tracking, planning and collaboration tool based around agile
+principles.
+
+It integrates with subversion and mercurial repositories and provides a means
+for planning projects, tracking issues and recording documentation.
+
diff --git a/bin/acl-check.php b/bin/acl-check.php
new file mode 100644 (file)
index 0000000..fb7f929
--- /dev/null
@@ -0,0 +1,30 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (function_exists('date_default_timezone_set')) {
+  date_default_timezone_set('UTC');
+}
+
+include dirname(__FILE__) . '/../inc/common.php';
+
+/* user object right... */
+
+array_shift($argv);
+$user = array_shift($argv);
+MTrackAuth::su($user);
+$objectid = array_shift($argv);
+
+/* A bit ugly to have this special case */
+if ($objectid == '--repo') {
+  $reponame = array_shift($argv);
+  $obj = MTrackRepo::loadByName($reponame);
+  $objectid = "repo:$obj->repoid";
+}
+
+$res = MTrackACL::hasAnyRights($objectid, $argv);
+
+if ($res) {
+  exit(0);
+}
+exit(1);
+
diff --git a/bin/codeshell b/bin/codeshell
new file mode 100755 (executable)
index 0000000..012581c
--- /dev/null
@@ -0,0 +1,154 @@
+#!/usr/bin/perl
+# vim:ts=2:sw=2:et:
+# For licensing and copyright terms, see the file named LICENSE
+use strict;
+use IO::File;
+
+# We are invoked by sshd as the repo serving user in place of the actual
+# command they requested.
+# Our purpose is to interpose and ensure that the underlying tool looks
+# only at the appropriate location
+
+# Our parameters are:
+# $1: path to the mtrack config.ini file
+# $2: the mtrack username
+# However, at least on OS/X, we get invoked as "-c '$1 $2'", so we need
+# to check for that.
+
+my ($inifile, $username, $mtrack) = @ARGV;
+
+if ($inifile eq '-c') {
+  require 'shellwords.pl';
+  @ARGV = &shellwords($username);
+  shift @ARGV;
+  ($inifile, $username, $mtrack) = @ARGV;
+}
+$ENV{MTRACK_CONFIG_FILE} = $inifile;
+
+# The command requested by the remote user is stored in this envvar.
+my $cmd = $ENV{SSH_ORIGINAL_COMMAND};
+
+sub validate_reponame {
+  my ($name) = @_;
+
+  if ($name =~ m/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/) {
+    if ($name !~ m/\.\./) {
+      my $base = get_cfg('repos', 'basedir');
+      if (! -d "$base/$name") {
+        print STDERR "Non-existant repo $name\n";
+        exit(1);
+      }
+
+      # Sanity check that we at least have checkout access
+      my $php = get_tool('php');
+      if (system($php, "$mtrack/bin/acl-check.php", $username, '--repo',
+          $name, 'checkout')) {
+        print STDERR "$username does not have checkout permission on $name\n";
+        exit(1);
+      }
+
+      return "$base/$name";
+    }
+  }
+  print STDERR "Invalid repo name $name\n";
+  exit(1);
+}
+
+my %CFG;
+
+sub read_config_file {
+  my $f = IO::File->new($inifile);
+  if (!$f) {
+    print STDERR "Unable to open ini file $inifile: $!\n";
+    exit(1);
+  }
+  my $sect = undef;
+  while (<$f>) {
+    my $line = $_;
+    $line =~ s/;.*$//;
+    $line =~ s/\s+$//;
+    if ($line =~ m/^\[(.*)\]$/) {
+      $sect = $1;
+      next;
+    }
+    if ($line =~ m/^(\S+)\s*=\s*"(.*)"$/) {
+      $CFG{$sect}{$1} = $2;
+      next;
+    }
+    if ($line =~ m/^(\S+)\s*=\s*(.*)$/) {
+      $CFG{$sect}{$1} = $2;
+      next;
+    }
+  }
+}
+
+sub get_cfg {
+  my ($sect, $name) = @_;
+  my $val;
+
+  if (not exists $CFG{$sect}) {
+    return undef;
+  }
+  if (not exists $CFG{$sect}{$name}) {
+    return undef;
+  }
+  $val = $CFG{$sect}{$name};
+
+  while ($val =~ m/\@\{(\S+):(\S+)\}/) {
+    my ($s, $k) = ($1, $2);
+
+    my $r = '';
+    if (exists $CFG{$s} and exists $CFG{$s}{$k}) {
+      $r = $CFG{$s}{$k};
+    }
+    $val =~ s/\@\{$s:$k\}/$r/g;
+  }
+  return $val;
+}
+
+read_config_file();
+
+sub get_tool {
+  my ($name) = @_;
+  my $tool = get_cfg('tools', $name);
+  if (-x $tool) {
+    return $tool;
+  }
+  print STDERR "tool $name is not configured\n";
+  exit(1);
+}
+
+$ENV{LOGNAME} = $username;
+if (0) {
+  open LOG, ">>/var/tmp/mtrack.ssh.session.log";
+  print LOG "$username $cmd\n";
+  close LOG;
+}
+
+if ($cmd =~ m/^hg -R (\S+) serve --stdio$/) {
+  my $name = validate_reponame($1);
+
+  my $hg = get_tool('hg');
+
+  exec($hg, '-R', $name, 'serve', '--stdio');
+}
+
+if ($cmd =~ m/^git-(\S+)\s+'(\S+)'$/) {
+  my ($verb, $name) = ($1, $2);
+  $name = validate_reponame($name);
+  my $git = get_tool('git');
+
+  exec($git, 'shell', '-c', "git-$verb '$name'");
+}
+
+if ($cmd eq 'svnserve -t') {
+  my $base = get_cfg('repos', 'basedir');
+  if (! -d $base) {
+    print STDERR "basedir $base does not exist\n";
+    exit(1);
+  }
+  my $svnserve = get_tool('svnserve');
+  exec($svnserve, '-r', $base, '-t', "--tunnel-user=$username");
+}
+
+print STDERR "Unsupported command:\n$cmd\n";
diff --git a/bin/data-move.php b/bin/data-move.php
new file mode 100644 (file)
index 0000000..44656c5
--- /dev/null
@@ -0,0 +1,164 @@
+<?php # vim:ts=2:sw=2:et:
+
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (function_exists('date_default_timezone_set')) {
+  date_default_timezone_set('UTC');
+}
+ini_set('memory_limit', -1);
+
+include_once dirname(__FILE__) . '/../inc/common.php';
+
+if (count($argv) != 2) {
+  echo "Usage: bin/data-move.php 'pgsql:dbname=foo;user=bar'\n";
+  echo <<<TXT
+Reads your existing mtrack database (uses DSN information in config.ini).
+Connects to the specified DSN and creates the mtrack schema, then populates
+it from your existing mtrack database.
+
+TXT;
+
+  exit(1);
+}
+/* destination DSN */
+$ddsn = $argv[1];
+
+$sdsn = MTrackConfig::get('core', 'dsn');
+if (!$sdsn) {
+  $sdsn = 'sqlite:' . MTrackConfig::get('core', 'dblocation');
+}
+$sdb = new PDO($sdsn);
+$sdb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+$ddb = new PDO($ddsn);
+$ddb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+$ddb->exec("set client_encoding='utf-8'");
+
+$driver = $ddb->getAttribute(PDO::ATTR_DRIVER_NAME);
+$adapter_class = "MTrackDBSchema_$driver";
+$adapter = new $adapter_class;
+
+$adapter->setDB($ddb);
+$vers = $adapter->determineVersion();
+echo "Version: ";
+var_dump($vers);
+
+$ddb->beginTransaction();
+
+$schemata = array();
+$latest = null;
+foreach (glob(dirname(__FILE__) . '/../schema/*.xml') as $filename) {
+  $latest = new MTrackDBSchema($filename);
+  $schemata[$latest->version] = $latest;
+}
+
+echo "Applying schema version $latest->version\n";
+foreach ($latest->tables as $t) {
+  $adapter->createTable($t);
+
+  $names = array();
+
+  foreach ($t->fields as $f) {
+    if ($f->type == 'autoinc') {
+      // Omit: we want the database to set this for us, otherwise
+      // sequence numbers won't get populated!
+      continue;
+    }
+    $names[] = $f->name;
+  }
+  $pull = 'select ' . join(',', $names) . ' from ' . $t->name;
+
+  $push = 'insert into ' . $t->name . '(' . join(',', $names) . ') values (' .
+    str_repeat('?,', count($names) - 1) . '?)';
+
+  $sq = $sdb->query($pull, PDO::FETCH_NUM);
+
+  $dq = $ddb->prepare($push);
+
+  foreach ($sq as $row) {
+    /* postgres has stronger data validation requirements;
+    * fixup the data */
+    $send = array();
+    foreach ($names as $i => $fname) {
+      $f = $t->fields[$fname];
+      switch ($f->type) {
+        case 'integer':
+        case 'autoinc':
+          if ($row[$i] == '') {
+            if (isset($f->nullable) && $f->nullable == '0') {
+              $row[$i] = 0;
+            } else {
+              $row[$i] = null;
+            }
+          }
+          $dq->bindValue(1+$i, $row[$i]);
+          break;
+        case 'real':
+          if ($row[$i] == '') {
+            if (isset($f->nullable) && $f->nullable == '0') {
+              $dq->bindValue(1+$i, 0.0);
+            } else {
+              $dq->bindValue(1+$i, null);
+            }
+          } else {
+            /* avoid converting to double here, for sake of precision.
+             * Also, somehow we have commas in our data... fix that */
+            $dq->bindValue(1+$i, str_replace(",", ".", $row[$i]));
+          }
+          break;
+        case 'blob':
+          if (is_null($row[$i])) {
+            $dq->bindValue(1+$i, null);
+          } else {
+            $stm = fopen('php://memory', 'r+');
+            fwrite($stm, $row[$i]);
+            rewind($stm);
+            $dq->bindValue(1+$i, $stm, PDO::PARAM_LOB);
+          }
+          break;
+        case 'text':
+        default:
+          /* CSV import could have injected non-UTF-8 data */
+          if (is_null($row[$i])) {
+            $dq->bindValue(1+$i, null);
+          } else {
+            $enc = mb_detect_encoding($row[$i], 'UTF-8,ISO-8859-1');
+            if ($enc != 'UTF-8') {
+              $dq->bindValue(1+$i,
+                mb_convert_encoding($row[$i], 'UTF-8', $enc));
+            } else {
+              $dq->bindValue(1+$i, $row[$i]);
+            }
+          }
+      }
+    }
+    try {
+      $dq->execute();
+    } catch (Exception $e) {
+      echo "$push\n";
+      var_dump($names);
+      var_dump($row);
+      var_dump($send);
+      foreach ($send as $d) {
+        echo bin2hex($d) . "\n";
+      }
+      throw $e;
+    }
+  }
+}
+if (isset($latest->post[$driver])) {
+  $ddb->exec($latest->post[$driver]);
+}
+$vers = $latest->version;
+
+
+
+
+$ddb->exec('delete from mtrack_schema');
+$q = $ddb->prepare('insert into mtrack_schema (version) values (?)');
+$q->execute(array($latest->version));
+$ddb->commit();
+
+
+
+
diff --git a/bin/git-commit-hook b/bin/git-commit-hook
new file mode 100755 (executable)
index 0000000..e1eefdb
--- /dev/null
@@ -0,0 +1,152 @@
+#!/usr/bin/env php
+<?php # vim:ts=2:sw=2:et:ft=php:
+/* For licensing and copyright terms, see the file named LICENSE */
+// called as:
+// git-commit-hook what [mtrackconfig]
+// the cwd is the repo path
+// a list of "oldrev newrev refname" lines is presented to us on stdin
+
+$action = $argv[1];
+if (isset($argv[2])) {
+  putenv("MTRACK_CONFIG_FILE=" . $argv[2]);
+}
+include dirname(__FILE__) . '/../inc/common.php';
+if (file_exists(MTrackConfig::get('core', 'vardir') . '/.initializing')) {
+  exit(0);
+}
+
+ini_set('display_errors', true);
+$GIT = MTrackConfig::get('tools', 'git');
+
+class GitCommitHookBridge implements IMTrackCommitHookBridge {
+  var $repo;
+  var $files = array();
+  var $log = array();
+  var $commits = array();
+
+  function __construct($repo) {
+    global $GIT;
+
+    $this->repo = $repo;
+
+    while (($line = fgets(STDIN)) !== false) {
+      list($old, $new, $ref) = explode(' ', trim($line), 3);
+      $this->commits[] = $new;
+
+      $fp = run($GIT, 'log', '--no-color', '--name-status',
+          '--date=rfc', $ref, "$old..$new");
+      $props = array();
+      $line = fgets($fp);
+      if (!preg_match("/^commit\s+(\S+)$/", $line)) {
+        throw new Exception("unexpected output from git log: $line");
+      }
+      while (($line = fgets($fp)) !== false) {
+        $line = rtrim($line);
+        if (!strlen($line)) break;
+        if (preg_match("/^(\S+):\s*(.*)\s*$/", $line, $M)) {
+          $props[$M[1]] = $M[2];
+        }
+      }
+      while (($line = fgets($fp)) !== false) {
+        $line = rtrim($line);
+        if (strncmp($line, '    ', 4)) {
+          break;
+        }
+        $this->log[] = substr($line, 4);
+      }
+      do {
+        if (preg_match("/^(.+)\s+(\S+)\s*$/", $line, $M)) {
+          $st = $M[1];
+          $file = $M[2];
+          $this->files[$file] = $new;
+        }
+      } while (($line = fgets($fp)) !== false);
+    }
+  }
+  function enumChangedOrModifiedFileNames() {
+    return array_keys($this->files);
+  }
+
+  function getCommitMessage() {
+    $log = join("\n", $this->log);
+    $log = preg_replace('/\[([a-fA-F0-9]+)\]/',
+      "[changeset:" . $this->repo->getBrowseRootName() . ",\$1]", $log);
+    return $log;
+  }
+
+  function getFileStream($path) {
+    global $GIT;
+    $rev = $this->files[$path];
+
+    // There may be a better way...
+    // ls-tree to determine the hash of the file from this change:
+    $fp = run($GIT, 'ls-tree', '-r', $rev, $path);
+    $line = fgets($fp);
+    $fp = null;
+    list($mode, $type, $hash, $name) = preg_split("/\s+/", $line);
+    // now we can cat that blob
+    return run($GIT, 'cat-file', 'blob', $hash);
+  }
+
+  function getChangesetDescriptor() {
+    $cs = array();
+    foreach ($this->commits as $ref) {
+      $cs[] = '[changeset:' . $this->repo->getBrowseRootName() . ",$ref]";
+    }
+    return join(", ", $cs);
+  }
+}
+
+try {
+  $repo = MTrackRepo::loadByLocation(getcwd());
+  $bridge = new GitCommitHookBridge($repo);
+  $author = MTrackAuth::whoami();
+  if ($author == 'anonymous') {
+    throw new Exception("cannot determine who you are");
+  }
+  $author = mtrack_canon_username($author);
+  MTrackAuth::su($author);
+  $checker = new MTrackCommitChecker($repo);
+  switch ($action) {
+    case 'pre':
+      $checker->preCommit($bridge);
+      break;
+    default:
+      $checker->postCommit($bridge);
+  }
+  exit(0);
+} catch (Exception $e) {
+  fwrite(STDERR, "\n" . $e->getMessage() .
+    "\n\n" .
+    $e->getTraceAsString() .
+    "\n\n ** Commit failed [$action]\n");
+
+  exit(1);
+}
+
+function run()
+{
+  $args = func_get_args();
+  $all_args = array();
+  foreach ($args as $a) {
+    if (is_array($a)) {
+      foreach ($a as $arg) {
+         $all_args[] = $arg;
+      }
+    } else {
+      $all_args[] = $a;
+    }
+  }
+
+  $cmd = '';
+
+  foreach ($all_args as $i => $arg) {
+    if ($i > 0) {
+      $cmd .= ' ';
+    }
+    $cmd .= escapeshellarg($arg);
+  }
+
+//  echo $cmd, "\n";
+  return popen($cmd, 'r');
+}
diff --git a/bin/hg-commit-hook b/bin/hg-commit-hook
new file mode 100755 (executable)
index 0000000..e2680b7
--- /dev/null
@@ -0,0 +1,193 @@
+#!/usr/bin/env php
+<?php # vim:ts=2:sw=2:et:ft=php:
+/* For licensing and copyright terms, see the file named LICENSE */
+// called as:
+// hg-commit-hook what [mtrackconfig]
+// the cwd is the repo path
+
+putenv("GATEWAY_INTERFACE=");
+
+$action = $argv[1];
+if (isset($argv[2])) {
+  putenv("MTRACK_CONFIG_FILE=" . $argv[2]);
+}
+include dirname(__FILE__) . '/../inc/common.php';
+if (file_exists(MTrackConfig::get('core', 'vardir') . '/.initializing')) {
+  exit(0);
+}
+
+ini_set('display_errors', true);
+$HG = MTrackConfig::get('tools', 'hg');
+if (!strlen($HG)) {
+  $HG = $_ENV['HG'];
+}
+$HG_NODE = $_ENV['HG_NODE'];
+if (!isset($_ENV['HG_PARENT1']) || !strlen($_ENV['HG_PARENT1'])) {
+  # figure out the parent
+  $p = stream_get_contents(run($HG, 'log', "-r$HG_NODE",
+         '--template', '{parents}'));
+  foreach (preg_split("/\s+/", $p) as $item) {
+    if (preg_match("/^(\d+):(\S+)$/", $item, $M)) {
+      if ($M[1] >= 0) {
+        $HG_PARENT1 = $M[2];
+        break;
+      }
+    }
+  }
+} else {
+  $HG_PARENT1 = $_ENV['HG_PARENT1'];
+}
+
+
+class HgCommitHookBridge implements IMTrackCommitHookBridge2 {
+  var $repo;
+  function __construct($repo) {
+    $this->repo = $repo;
+  }
+
+  function getChanges() {
+    global $HG_NODE;
+    global $HG;
+    $cs = array();
+    $log = popen("$HG log -r$HG_NODE: --template '{node|short}\n{author|email}\n{date|hgdate}\n{desc|nonempty|tabindent}\n'", 'r');
+    $line = fgets($log);
+    do {
+      $c = new MTrackCommitHookChangeEvent;
+
+      $node = trim($line);
+      $c->hash = $node;
+      $c->rev = "[changeset:" . $this->repo->getBrowseRootName() . ",$node]";
+
+      $author = trim(fgets($log));
+      $c->changeby = mtrack_canon_username($author);
+
+      $date = fgets($log);
+      if (!preg_match("/^(\d+)\s+\d+$/", $date, $M)) {
+        throw new Exception("failed to parse date $date");
+      }
+      $c->ctime = MTrackDB::unixtime((int)$M[1]);
+
+      $msg = fgets($log);
+      do {
+        $line = fgets($log);
+        if ($line === false) {
+          break;
+        }
+        if (preg_match("/^[a-fA-F0-9]+$/", $line)) {
+          break;
+        }
+        $msg .= substr($line, 1);
+      } while (true);
+      $c->changelog = rtrim($msg);
+      $cs[] = $c;
+    } while ($line !== false);
+
+    return $cs;
+  }
+
+  function enumChangedOrModifiedFileNames() {
+    global $HG;
+    global $HG_NODE;
+
+    $files = array();
+    $fp = popen("$HG log -r$HG_NODE: --template '{files}\n'", 'r');
+    while (($line = fgets($fp)) !== false) {
+      foreach (preg_split("/\s+/", $line) as $path) {
+        if (strlen($path)) {
+          $files[] = $path;
+        }
+      }
+    }
+    return $files;
+  }
+
+  function getCommitMessage() {
+    global $HG;
+    global $HG_NODE;
+    $fp = popen("$HG log -r$HG_NODE: --template '{desc}\n\n'", 'r');
+    $log = stream_get_contents($fp);
+    $log = preg_replace('/\[(\d+)\]/',
+      "[changeset:" . $this->repo->getBrowseRootName() . ",\$1]", $log);
+    return $log;
+  }
+
+  function getFileStream($path) {
+    global $HG;
+    global $HG_NODE;
+    return popen("$HG cat $path", 'r');
+  }
+
+  function getChangesetDescriptor() {
+    global $HG_NODE;
+    global $HG;
+    $cs = array();
+    $nodes = popen("$HG log -r$HG_NODE: --template '{node|short}\n'", 'r');
+    while (($line = fgets($nodes)) !== false) {
+      $n = trim($line);
+      $cs[] = '[changeset:' . $this->repo->getBrowseRootName() . ",$n]";
+    }
+    return join(", ", $cs);
+  }
+}
+
+try {
+  $repo = MTrackRepo::loadByLocation(getcwd());
+  $bridge = new HgCommitHookBridge($repo);
+  /* for pushes, respect OS indication of who this is, unless we don't
+   * know; we'll use the info from the changeset in that case */
+  $author = 'anonymous';
+  if (strstr($action, 'group')) {
+    $author = MTrackAuth::whoami();
+  }
+  if ($author == 'anonymous') {
+    $author = trim(
+              shell_exec("$HG log -r$HG_NODE: --template '{author|email}'"));
+  }
+  $author = mtrack_canon_username($author);
+  MTrackAuth::su($author);
+  $checker = new MTrackCommitChecker($repo);
+  switch ($action) {
+    case 'pretxncommit':
+    case 'pretxnchangegroup':
+      $checker->preCommit($bridge);
+      break;
+    default:
+      $checker->postCommit($bridge);
+  }
+  exit(0);
+} catch (Exception $e) {
+  /* Errors must render to STDERR, or they won't show up in the hg client */
+  fwrite(STDERR, "\n" . $e->getMessage() .
+    "\n\n" .
+    $e->getTraceAsString() .
+    "\n\n ** Commit failed [$action]\n");
+
+  exit(1);
+}
+
+function run()
+{
+  $args = func_get_args();
+  $all_args = array();
+  foreach ($args as $a) {
+    if (is_array($a)) {
+      foreach ($a as $arg) {
+         $all_args[] = $arg;
+      }
+    } else {
+      $all_args[] = $a;
+    }
+  }
+
+  $cmd = '';
+
+  foreach ($all_args as $i => $arg) {
+    if ($i > 0) {
+      $cmd .= ' ';
+    }
+    $cmd .= escapeshellarg($arg);
+  }
+
+//  echo $cmd, "\n";
+  return popen($cmd, 'r');
+}
diff --git a/bin/import-trac.php b/bin/import-trac.php
new file mode 100644 (file)
index 0000000..133ace1
--- /dev/null
@@ -0,0 +1,908 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+// Imports data from a trac sqlite database
+
+$name_map = array(
+    'description' => 'content',
+    'type' => 'classification',
+    'estimatedhours' => 'estimated',
+    'ec_branches' => 'branches',
+    'ec_features' => 'features',
+);
+
+$trac_wiki_names = array(
+  'TracAccessibility' => true,
+  'TracAdmin' => true,
+  'TracBackup' => true,
+  'TracBrowser' => true,
+  'TracCgi' => true,
+  'TracChangeset' => true,
+  'TracEnvironment' => true,
+  'TracFastCgi' => true,
+  'TracGuide' => true,
+  'TracImport' => true,
+  'TracIni' => true,
+  'TracInstall' => true,
+  'TracInstallPlatforms' => true,
+  'TracInterfaceCustomization' => true,
+  'TracLinks' => true,
+  'TracLogging' => true,
+  'TracModPython' => true,
+  'TracMultipleProjects' => true,
+  'TracNotification' => true,
+  'TracPermissions' => true,
+  'TracPlugins' => true,
+  'TracQuery' => true,
+  'TracReports' => true,
+  'TracRevisionLog' => true,
+  'TracRoadmap' => true,
+  'TracRss' => true,
+  'TracSearch' => true,
+  'TracStandalone' => true,
+  'TracSupport' => true,
+  'TracSyntaxColoring' => true,
+  'TracTickets' => true,
+  'TracTicketsCustomFields' => true,
+  'TracTimeline' => true,
+  'TracUnicode' => true,
+  'TracUpgrade' => true,
+  'TracWiki' => true,
+  'WikiDeletePage' => true,
+  'WikiFormatting' => true,
+  'WikiHtml' => true,
+  'WikiMacros' => true,
+  'WikiNewPage' => true,
+  'WikiPageNames' => true,
+  'WikiProcessors' => true,
+  'WikiRestructuredText' => true,
+  'WikiRestructuredTextLinks' => true,
+  'CamelCase' => true,
+  'InterMapTxt' => true,
+  'InterTrac' => true,
+  'InterWiki' => true,
+  'RecentChanges' => true,
+  'SandBox' => true,
+  'TitleIndex' => true,
+);
+
+function trac_date($unix) {
+  return MTrackDB::unixtime($unix);
+}
+
+function trac_get_comp($name, $deleted = true)
+{
+  global $CS;
+  global $components_by_name;
+  
+  if (!strlen($name)) return null;
+
+  $comp = $components_by_name[$name];
+  if ($comp === null) {
+    /* no longer exists */
+    $comp = new MTrackComponent;
+    $comp->name = $name;
+    $comp->deleted = $deleted;
+    $comp->save($CS);
+    $components_by_name[$comp->name] = $comp;
+  }
+  return $comp;
+}
+
+function trac_assoc_comp_and_proj(MTrackComponent $comp, MTrackProject $proj)
+{
+  static $comp_assoc = array();
+
+  if (isset($comp_assoc[$proj->shortname][$comp->name])) {
+    return;
+  }
+
+  MTrackDB::q('insert into components_by_project (projid, compid)
+    values (?, ?)', $proj->projid, $comp->compid);
+
+  $comp_assoc[$proj->shortname][$comp->name] = true;
+}
+
+function trac_add_user($username)
+{
+  static $users = array();
+  global $CANON_USERS;
+  
+  $username = trim($username);
+  $username = strtolower($username);
+
+  while (isset($CANON_USERS[$username])) {
+    $username = strtolower($CANON_USERS[$username]);
+  }
+
+  if (preg_match('/[ ,]/', $username)) {
+    // invalid: attempted to set multiple people.
+    // take the first one
+    list($username) = preg_split('/[ ,]+/', $username);
+
+    while (isset($CANON_USERS[$username])) {
+      $username = strtolower($CANON_USERS[$username]);
+    }
+  }
+
+  if (preg_match('/^\d+(\.\d+)?$/', $username)) {
+    // invalid (looks like a version number)
+    return null;
+  }
+
+  if ($username == 'somebody' || $username == '') {
+    return null;
+  }
+
+  if (isset($users[$username])) {
+    return $username;
+  }
+
+  $users[$username] = true;
+  switch ($username) {
+    case 'trac':
+      $active = 0;
+      break;
+    default:
+      $active = 1;
+  }
+  try {
+    MTrackDB::q(
+    'insert into userinfo (userid, active) values (?, ?)',
+    $username, $active);
+  } catch (Exception $e) {
+  }
+
+  return $username;
+}
+
+function trac_get_milestone($name, MTrackProject $proj)
+{
+  global $CS;
+  global $milestone_by_name;
+  static $alias = array();
+
+  $lname = strtolower($name);
+  if (isset($alias[$proj->shortname][$lname])) {
+    $name = $alias[$proj->shortname][$lname];
+  } else {
+    $alias[$proj->shortname][$lname] = $name;
+  }
+
+  $ms = $milestone_by_name[$lname];
+  if ($ms === null) {
+    /* first see if there's a milestone with this name in another project */
+    $ms = MTrackMilestone::loadByName($name);
+    if ($ms) {
+      $alias[$proj->shortname][$lname] .= " ($proj->shortname)";
+      $name = $alias[$proj->shortname][$lname];
+    }
+      
+    $ms = new MTrackMilestone();
+    $ms->name = $name;
+    $ms->deleted = true;
+    $ms->description = '';
+    $ms->save($CS);
+    $milestone_by_name[$lname] = $ms;
+  }
+  return $ms;
+}
+
+function trac_get_keyword($word)
+{
+  static $words = array();
+
+  if (isset($words[$word])) {
+    return $words[$word];
+  }
+
+  $kw = MTrackKeyword::loadByWord($word);
+
+  if (!$kw) {
+    global $CS;
+    $kw = new MTrackKeyword;
+    $kw->keyword = $word;
+    $kw->save($CS);
+  }
+
+  $words[$word] = $kw;
+
+  return $kw;
+}
+
+function progress($msg)
+{
+  static $events = 0;
+  static $last = 0;
+  static $clr_eol = null;
+  static $clr_eod = null;
+
+  if ($clr_eol === null) {
+    /* el: clr_eol
+     * ed: clr_eos
+     */
+    $clr_eol = shell_exec("tput el");
+    $clr_eod = shell_exec("tput ed");
+  }
+
+  $events++;
+
+  $now = time();
+
+  if ($events % 10 || $now - $last > 2) {
+    echo "\r$clr_eod$msg"; flush();
+  }
+  $last = $now;
+}
+  
+$components_by_name = array();
+
+function adjust_links($reason, $ticket_prefix, MTrackProject $project)
+{
+  return $project->adjust_links($reason, $ticket_prefix);
+}
+
+function import_from_trac(MTrackProject $project, $import_from_db, $ticket_prefix = false)
+{
+  global $components_by_name;
+  global $milestone_by_name;
+
+  echo "Importing trac database $import_from_db\n"; flush();
+
+  $start_import = time();
+
+  /* reset this list so that we can detect conflicting names
+   * across projects */
+  $milestone_by_name = array();
+
+
+  if (!file_exists("$import_from_db/db/trac.db")) {
+    echo "No such file $import_from_db/db/trac.db\n";
+    exit(1);
+  }
+
+  $trac = new PDO('sqlite:' . $import_from_db . "/db/trac.db");
+  $trac->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+  //date_default_timezone_set('UTC');
+
+  $CS = MTrackChangeset::begin('~import~', "Import trac from $import_from_db");
+
+  foreach ($trac->query(
+        "select type, name, value from enum")->fetchAll()
+      as $row) {
+
+    if ($row['type'] == 'priority') {
+      try {
+        $pri = MTrackPriority::loadByName($row['name']);
+      } catch (Exception $e) {
+        $pri = new MTrackPriority;
+        $pri->name = $row['name'];
+        $pri->value = $row['value'];
+        $pri->save($CS);
+      }
+    }
+
+    if ($row['type'] == 'severity') {
+      try {
+        $pri = MTrackSeverity::loadByName($row['name']);
+      } catch (Exception $e) {
+        $pri = new MTrackSeverity;
+        $pri->name = $row['name'];
+        $pri->value = $row['value'];
+        $pri->save($CS);
+      }
+    }
+
+    if ($row['type'] == 'resolution') {
+      try {
+        $pri = MTrackResolution::loadByName($row['name']);
+      } catch (Exception $e) {
+        $pri = new MTrackResolution;
+        $pri->name = $row['name'];
+        $pri->value = $row['value'];
+        $pri->save($CS);
+      }
+    }
+
+    if ($row['type'] == 'ticket_type') {
+      try {
+        $pri = MTrackClassification::loadByName($row['name']);
+      } catch (Exception $e) {
+        $pri = new MTrackClassification;
+        $pri->name = $row['name'];
+        $pri->value = $row['value'];
+        $pri->save($CS);
+      }
+    }
+  }
+
+  foreach ($trac->query('select name from component')->fetchAll() as $row) {
+    $comp = trac_get_comp($row['name'], false);
+    trac_assoc_comp_and_proj($comp, $project);
+  }
+
+  foreach ($trac->query("SELECT * from milestone order by name")
+      ->fetchAll(PDO::FETCH_ASSOC) as $row) {
+    /* first see if there's a milestone with this name in another project */
+    $name = $row['name'];
+    $ms = MTrackMilestone::loadByName($name);
+    if ($ms) {
+      $name .= ' (' . $project->shortname . ')';
+    }
+    $ms = new MTrackMilestone();
+    $ms->name = $name;
+    /* for names of the form: sprint.1 sprint.2, tie them back as
+     * children of "sprint" */
+    if (preg_match("/^(.*)\.(\d+)$/", $name, $M)) {
+      $pms = $milestone_by_name[strtolower($M[1])];
+      if ($pms !== null) {
+        $ms->pmid = $pms->mid;
+      }
+    }
+    $ms->description = $row['description'];
+    $ms->duedate = trac_date($row['due']);
+    $ms->completed = trac_date($row['completed']);
+    $ms->save($CS);
+    $milestone_by_name[strtolower($row['name'])] = $ms;
+  }
+
+  $CS->commit();
+  $CS = null;
+
+  list($maxtkt) = $trac->query("select max(id) from ticket")->fetchAll(PDO::FETCH_COLUMN, 0);
+  MTrackConfig::append('trac_import',
+    "max_ticket:$project->shortname", $maxtkt);
+
+  /* first pass is to reserve ticket ids that match the trac db */
+  foreach ($trac->query(
+        "SELECT * from ticket order by id")
+      ->fetchAll(PDO::FETCH_ASSOC) as $row) {
+    
+    $row['reporter'] = trac_add_user($row['reporter']);
+    progress("issue $row[id] $row[reporter]");
+
+    $fields = array('summary', 'description', 'resolution', 'status',
+        'owner', 'summary', 'component', 'priority', 'severity',
+        'changelog',
+        'version', 'cc', 'keywords', 'milestone', 'reporter', 'type');
+
+    foreach ($trac->query(
+          "select name, value from ticket_custom where ticket='$row[id]'")
+        ->fetchAll(PDO::FETCH_ASSOC) as $custom) {
+      if (strlen($custom['value'])) {
+        $field = $custom['name'];
+        $row[$field] = $custom['value'];
+        $fields[] = $field;
+      }
+    }
+
+    /* take a peek at the change history on the ticket to see if we can
+     * determine the original field values */
+    foreach ($fields as $field) {
+      foreach ($trac->query(
+            "SELECT oldvalue from ticket_change where ticket = '" .
+            $row['id'] . "' and field='$field' order by time LIMIT 1")
+          ->fetchAll(PDO::FETCH_ASSOC) as $hist) {
+        if (!strlen($hist['oldvalue'])) {
+          $row[$field] = null;
+        } else {
+          $row[$field] = $hist['oldvalue'];
+        }
+      }
+    }
+
+    $ctime = (int)$row['time'];
+
+    MTrackAuth::su($row['reporter']);
+    $CS = MTrackChangeset::begin('ticket:X', $row['summary'], $ctime);
+
+    $issue = new MTrackIssue();
+    $issue->summary = $row['summary'];
+    $issue->description = adjust_links($row['description'], $ticket_prefix, $project);
+    $issue->priority = $row['priority'];
+    $issue->classification = $row['type'];
+    $issue->resolution = $row['resolution'];
+    $issue->severity = $row['severity'];
+    $issue->changelog = $row['changelog'];
+    $issue->cc = $row['cc'];
+
+    $issue->addEffort(0, $row['estimatedhours']);
+    $issue->addEffort($row['totalhours']);
+
+    if (strlen($row['component'])) {
+      $comp = trac_get_comp($row['component']);
+      $issue->assocComponent($comp);
+    }
+    if (strlen($row['milestone'])) {
+      $ms = trac_get_milestone($row['milestone'], $project);
+      $issue->assocMilestone($ms);
+    }
+
+    foreach (array('keywords', 'features', 'ec_features',
+          'version',
+          'branches', 'ec_branches') as $field) {
+      foreach (preg_split("/\s+/", $row[$field]) as $w) {
+        if (strlen($w)) {
+          $kw = trac_get_keyword($w);
+          $issue->assocKeyword($kw);
+        }
+      }
+    }
+
+    if (strlen($row['owner']) && $row['owner'] != 'somebody') {
+      $row['owner'] = trac_add_user($row['owner']);
+      $issue->owner = $row['owner'];
+    }
+
+    if ($ticket_prefix) {
+      $issue->nsident = $project->shortname . $row['id'];
+    } else {
+      $issue->nsident = $row['id'];
+    }
+
+    $issue->save($CS);
+
+#    if ($issue->tid != $row['id']) {
+#      throw new Exception(
+#          "expected doc to be created with $row[id], got $issue->tid");
+#    }
+    $CS->setObject("ticket:" . $issue->tid);
+    $CS->commit();
+    $CS = null;
+    $issue = null;
+    MTrackAuth::drop();
+  }
+
+  /* now make a pass through the history to flesh out the comments and
+   * other changes.
+   * This can use up a surprising amount of memory, so we stage in
+   * the work. */
+
+  echo "\nLooking for changes in $import_from_db\n"; flush();
+
+  $changes = $trac->query(
+      "select distinct time, ticket, author from 
+      ticket_change order by ticket asc, time, author")
+    ->fetchAll(PDO::FETCH_NUM);
+
+  foreach ($changes as $i => $row) {
+    // we order by field because we always want "estimatedhours"
+    // to apply before "hours"
+    $q = $trac->prepare(
+        "select * from ticket_change
+        where time = ? and ticket = ? and author = ?
+        order by field
+        ");
+    $q->execute($row);
+    $batch = $q->fetchAll(PDO::FETCH_ASSOC);
+    if (empty($batch)) continue;
+    list($first) = $batch;
+    global $CS;
+
+    $first['author'] = trac_add_user($first['author']);
+    MTrackAuth::su($first['author']);
+    try {
+      progress("issue $first[ticket] changed by $first[author]");
+
+      if ($ticket_prefix) {
+        $issue = MTrackIssue::loadByNSIdent(
+                  $project->shortname . $first['ticket']);
+      } else {
+        $issue = MTrackIssue::loadByNSIdent($first['ticket']);
+      }
+
+      $CS = MTrackChangeset::begin("ticket:" . $issue->tid,
+          "changed", $first['time']);
+
+
+      foreach ($batch as $row) {
+        switch ($row['field']) {
+          case 'comment':
+            $row['newvalue'] = adjust_links($row['newvalue'], $ticket_prefix, $project);
+            $issue->addComment($row['newvalue']);
+            $CS->setReason($row['newvalue']);
+            break;
+
+          case 'owner':
+            $row['newvalue'] = trac_add_user($row['newvalue']);
+            if ($row['newvalue'] == 'somebody') {
+              $issue->owner = null;
+            } else {
+              $issue->owner = $row['newvalue'];
+            }
+            break;
+
+          case 'status':
+            if ($row['newvalue'] == 'closed') {
+              $issue->close();
+            } else {
+              $issue->status = $row['newvalue'];
+            }
+            break;
+
+          case 'description':
+            $issue->description = adjust_links($row['newvalue'],
+                                    $ticket_prefix, $project);
+            break;
+
+          case 'resolution':
+          case 'summary':
+          case 'priority':
+          case 'severity':
+          case 'changelog':
+          case 'cc':
+            $name = $row['field'];
+            $issue->$name = $row['newvalue'];
+            break;
+
+          case 'component':
+            foreach ($issue->getComponents() as $comp) {
+              $comp = trac_get_comp($comp);
+              if ($comp) {
+                $issue->dissocComponent($comp);
+              }
+            }
+            if (strlen($row['newvalue'])) {
+              $comp = trac_get_comp($row['newvalue']);
+              $issue->assocComponent($comp);
+            }
+            break;
+
+          case 'milestone':
+            foreach ($issue->getMilestones() as $ms) {
+              $ms = trac_get_milestone($ms, $project);
+              if ($ms) {
+                $issue->dissocMilestone($ms);
+              }
+            }
+            if (strlen($row['newvalue'])) {
+              $ms = trac_get_milestone($row['newvalue'], $project);
+              $issue->assocMilestone($ms);
+            }
+            break;
+
+          case 'keywords':
+          case 'features':
+          case 'ec_features':
+          case 'ec_branches':
+          case 'branches':
+          case 'version':
+            foreach ($issue->getKeywords() as $w) {
+              $kw = trac_get_keyword($w);
+              $issue->dissocKeyword($kw);
+            }
+            foreach (preg_split("/\s+/", $row['newvalue']) as $w) {
+              if (strlen($w)) {
+                $kw = trac_get_keyword($w);
+                $issue->assocKeyword($kw);
+              }
+            }
+            break;
+
+          case 'type':
+            $issue->classification = $row['newvalue'];
+            break;
+
+          case 'totalhours':
+          case 'reporter':
+            /* ignore */
+            break;
+
+          case 'hours':
+            $issue->addEffort($row['newvalue'] + 0);
+            break;
+
+          case 'estimatedhours':
+            $issue->addEffort(0, $row['newvalue'] + 0);
+            break;
+
+          default:
+            throw new Exception("cant handle field $row[field]");
+        }
+      }
+      $issue->save($CS); 
+      $issue = null;
+      $CS->commit();
+      $CS = null;
+
+    } catch (Exception $e) {
+      MTrackAuth::drop();
+      throw $e;
+    }
+    MTrackAuth::drop();
+  }
+
+  /* Find attachments */
+  foreach ($trac->query(
+      "select id, filename, size, time, description, author
+        from attachment where type = 'ticket'")
+      ->fetchAll(PDO::FETCH_ASSOC) as $row) {
+
+    MTrackAuth::su($row['author']);
+    try {
+      $row['author'] = trac_add_user($row['author']);
+      $row['filename'] = trac_attachment_name($row['filename']);
+      progress("issue $row[id] attachment $row[filename] $row[author]");
+
+      if ($ticket_prefix) {
+        $issue = MTrackIssue::loadByNSIdent(
+            $project->shortname . $row['id']);
+      } else {
+        $issue = MTrackIssue::loadByNSIdent($row['id']);
+      }
+
+      $CS = MTrackChangeset::begin("ticket:" . $issue->tid,
+          $row['description'], $row['time']);
+
+      $afile = $import_from_db . "/attachments/ticket/$row[id]/";
+
+      // trac uses weird url encoding on the filename on disk.
+      // this weird looking code is because I'm too lazy to reverse
+      // engineer their encoding
+      foreach (glob("$afile/*") as $potential) {
+        if (trac_attachment_name(basename($potential)) == $row['filename']) {
+          $afile = $potential;
+          break;
+        }
+      }
+      MTrackAttachment::add("ticket:$issue->tid",
+          $afile, $row['filename'], $CS);
+      $CS->commit();
+
+    } catch (Exception $e) {
+      MTrackAuth::drop();
+      throw $e;
+    }
+    MTrackAuth::drop();
+  }
+
+  /* Make another pass over the tickets to catch changes made to the
+   * database by hand that are not journalled in the trac change tables */
+  MTrackAuth::su('trac');
+  foreach ($trac->query(
+        "SELECT * from ticket order by id")
+      ->fetchAll(PDO::FETCH_ASSOC) as $row) {
+
+    $fields = array('summary', 
+        'description',
+        'resolution', 'status',
+        'owner', 'summary', 'component', 'priority', 'severity',
+        'changelog',
+        'version', 'cc', 'keywords', 'milestone', 'reporter', 'type');
+
+    foreach ($trac->query(
+          "select name, value from ticket_custom where ticket=$row[id]")
+        ->fetchAll(PDO::FETCH_ASSOC) as $custom) {
+      if (strlen($custom['value'])) {
+        $field = $custom['name'];
+        if ($field == 'description') {
+          $custom['value'] = adjust_links($custom['value'], $ticket_prefix, $project);
+        }
+
+        $row[$field] = $custom['value'];
+        $fields[] = $field;
+      }
+    }
+
+    if ($ticket_prefix) {
+      $issue = MTrackIssue::loadByNSIdent($project->shortname . $row['id']);
+    } else {
+      $issue = MTrackIssue::loadByNSIdent($row['id']);
+    }
+    $needed = false;
+
+    $row['owner'] = trac_add_user($row['owner']);
+    $fmap = array(
+      'summary',
+      'description',
+      'priority',
+      'status',
+      'classification' => 'type',
+      'resolution',
+      'owner',
+      'severity');
+
+    foreach ($fmap as $sname => $fname) {
+      if (is_int($sname) || ctype_digit($sname)) {
+        $sname = $fname;
+      }
+      if ($fname == 'description') {
+        $row[$fname] = adjust_links($row[$fname], $ticket_prefix, $project);
+      }
+      if ($issue->$sname != $row[$fname]) {
+        $needed = true;
+        $issue->$sname = $row[$fname];
+      }
+    }
+
+    $comp = reset($issue->getComponents());
+    if ($comp != $row['component']) {
+      $needed = true;
+      $issue->dissocComponent(trac_get_comp($comp));
+      if (strlen($row['component'])) {
+        $comp = trac_get_comp($row['component']);
+        $issue->assocComponent($comp);
+      }
+    }
+
+    $ms = reset($issue->getMilestones());
+    if ($ms != $row['milestone']) {
+      $needed = true;
+      $issue->dissocMilestone(trac_get_milestone($ms, $project));
+      if (strlen($row['milestone'])) {
+        $ms = trac_get_milestone($row['milestone'], $project);
+        $issue->assocMilestone($ms);
+      }
+    }
+
+    if ($needed) {
+      progress("$row[id] fixup");
+      if ($issue->updated) {
+        $last_cs = MTrackChangeset::get($issue->updated);
+      } else {
+        $last_cs = MTrackChangeset::get($issue->created);
+      }
+      $issue->addComment(
+        "The importer detected manual database changes; " .
+        "revising ticket to match");
+      $CS = MTrackChangeset::begin("ticket:" . $issue->tid,
+            "fixup", 
+            strtotime($last_cs->when));
+      $issue->save($CS);
+      $CS->commit();
+    }
+  }
+  MTrackAuth::drop();
+
+  echo "\nProcessing wiki pages\n"; flush();
+
+  /* wiki, jungle is posse */
+  global $trac_wiki_names;
+  $wiki = null;
+
+  $wiki_page_remap = array();
+  $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
+  if (!strlen($suf)) {
+    /* Here's a fun problem; trac allows both pages and dirs to exist with the
+     * same name (because its dirs aren't really dirs, they're just illusions)
+     * We need to notice those that are pages and that collide with dirs and
+     * rename them */
+    $all_wiki_page_names = array();
+    foreach ($trac->query(
+          "select distinct name from wiki")->fetchAll(PDO::FETCH_COLUMN, 0)
+        as $name) {
+      $all_wiki_page_names[$name] = $name;
+    }
+
+    foreach ($all_wiki_page_names as $name) {
+      $elements = explode('/', $name);
+      if (count($elements) > 1) {
+        $accum = array();
+        while (count($elements) > 1) {
+          $accum[] = array_shift($elements);
+          $n = join('/', $accum);
+          if (isset($all_wiki_page_names[$n])) {
+            // Collision; try adding a suffix of "Page"
+            if (!isset($all_wiki_page_names[$n . 'Page'])) {
+              $wiki_page_remap[$n] = $n . 'Page';
+            } else {
+              throw new Exception("wiki collision between $n and $name");
+            }
+          }
+        }
+      }
+    }
+    echo "The following pages will be renamed\n";
+    print_r($wiki_page_remap);
+  }
+
+  foreach ($trac->query(
+        "SELECT * from wiki order by time, name, version")
+      ->fetchAll(PDO::FETCH_ASSOC) as $row) {
+
+    if (isset($trac_wiki_names[$row['name']])) {
+      continue;
+    }
+    if (isset($wiki_page_remap[$row['name']])) {
+      $row['name'] = $wiki_page_remap[$row['name']];
+    }
+
+    $author = trac_add_user($row['author']);
+    try {
+      MTrackAuth::su($author);
+      $row['author'] = $author;
+    } catch (Exception $e) {
+      echo "Error while assuming $author ($row[author])\n";
+      MTrackAuth::drop();
+      throw $e;
+    }
+    if ($ticket_prefix) {
+      $row['name'] = $project->shortname . '/' . $row['name'];
+    }
+    $CS = MTrackChangeset::begin('wiki:' . $row['name'],
+        $row['comment'], $row['time']);
+    if (!is_object($wiki) || $wiki->pagename != $row['name']) {
+      $wiki = MTrackWikiItem::loadByPageName($row['name']);
+    }
+    if (!$wiki) {
+      $wiki = new MTrackWikiItem($row['name']);
+    }
+    progress("$row[name] $row[version]");
+    $wiki->content = adjust_links($row['text'], $ticket_prefix, $project);
+    $wiki->save($CS);
+    $CS->commit();
+    MTrackAuth::drop();
+  }
+  /* Find attachments */
+  foreach ($trac->query(
+      "select id, filename, size, time, description, author
+        from attachment where type = 'wiki'")
+      ->fetchAll(PDO::FETCH_ASSOC) as $row) {
+
+    MTrackAuth::su($row['author']);
+    try {
+      $row['author'] = trac_add_user($row['author']);
+      $row['filename'] = trac_attachment_name($row['filename']);
+
+      progress("wiki $row[id] attachment $row[filename] $row[author]");
+
+      if ($ticket_prefix) {
+        $name = $project->shortname . '/' . $row['id'];
+      } else {
+        $name = $row['id'];
+      }
+
+      $wiki = MTrackWikiItem::loadByPageName($name);
+      if (!$wiki) {
+        MTrackAuth::drop();
+        continue;
+      }
+
+      $CS = MTrackChangeset::begin('wiki:' . $name,
+          $row['description'], $row['time']);
+
+      $afile = $import_from_db . "/attachments/wiki/$row[id]/";
+
+      // trac uses weird url encoding on the filename on disk.
+      // this weird looking code is because I'm too lazy to reverse
+      // engineer their encoding
+      foreach (glob("$afile/*") as $potential) {
+        if (trac_attachment_name(basename($potential)) == $row['filename']) {
+          $afile = $potential;
+          break;
+        }
+      }
+      if (!is_file($afile)) {
+        echo "Looking for attachment $row[filename]\n";
+        echo "Didn't find it in $afile\n";
+        $g = glob("$afile/*");
+        print_r($g);
+        foreach ($g as $f) {
+          echo trac_attachment_name($f), "\n";
+        }
+        throw new Exception("fail");
+      }
+      MTrackAttachment::add("wiki:$name",
+          $afile, $row['filename'], $CS);
+      $CS->commit();
+
+    } catch (Exception $e) {
+      MTrackAuth::drop();
+      throw $e;
+    }
+    MTrackAuth::drop();
+  }
+
+
+  $end_import = time();
+  $elapsed = $end_import - $start_import;
+  echo "\nDone with $import_from_db (in $elapsed seconds)\n"; flush();
+}
+
+function trac_attachment_name($name)
+{
+  $name = urldecode($name);
+  $name = str_replace('+', ' ', $name);
+  return $name;
+}
diff --git a/bin/init.php b/bin/init.php
new file mode 100644 (file)
index 0000000..4f9cb78
--- /dev/null
@@ -0,0 +1,526 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (function_exists('date_default_timezone_set')) {
+  date_default_timezone_set('UTC');
+}
+
+ini_set('memory_limit', 256*1024*1024);
+$_GLOBALS['MTRACK_CONFIG_SKIP_BOOT'] = true;
+include 'inc/common.php';
+include 'bin/import-trac.php';
+
+if (!file_exists("bin/init.php")) {
+  echo "You must run me from the top-level mtrack dir\n";
+  exit(1);
+}
+
+/* People doing this are not necessarily sane, make sure we have PDO and
+ * pdo_sqlite */
+if (!extension_loaded('PDO') || !extension_loaded('pdo_sqlite')) {
+  echo "Mtrack requires PDO and pdo_sqlite to function\n";
+  exit(1);
+}
+
+$projects = array();
+$repos = array();
+$tracs = array();
+$links = array();
+$config_file_name = 'config.ini';
+$vardir = 'var';
+$aliasfile = null;
+$authorfile = null;
+$wiki_repo_type = null;
+$DSN = null;
+
+$SCMS = MTrackRepo::getAvailableSCMs();
+
+$args = array();
+array_shift($argv);
+while (count($argv)) {
+  $arg = array_shift($argv);
+
+  if ($arg == '--trac') {
+    if (count($argv) < 2) {
+      usage("Missing arguments to --trac");
+    }
+    $pname = array_shift($argv);
+    $tracdb = array_shift($argv);
+
+    if (!file_exists($tracdb)) {
+      usage("Tracdb path must be a sqlite database");
+    }
+    $tracs[$tracdb] = $pname;
+    $projects[$pname] = $pname;
+    continue;
+  }
+
+  if ($arg == '--author-alias') {
+    if (count($argv) < 1) {
+      usage("Missing argument to --author-alias");
+    }
+    $aliasfile = array_shift($argv);
+    continue;
+  }
+  if ($arg == '--author-info') {
+    if (count($argv) < 1) {
+      usage("Missing argument to --author-info");
+    }
+    $authorfile = array_shift($argv);
+    continue;
+  }
+
+  if ($arg == '--wiki-type') {
+    if (count($argv) < 1) {
+      usage("Missing argument to --wiki-type");
+    }
+    $wiki_repo_type = array_shift($argv);
+    if (!isset($SCMS[$wiki_repo_type])) {
+      usage("Invalid repo type $wiki_repo_type");
+    }
+    continue;
+  }
+
+  if ($arg == '--repo') {
+    if (count($argv) < 3) {
+      usage("Missing arguments to --repo");
+    }
+    $rname = array_shift($argv);
+    $rtype = array_shift($argv);
+    $rpath = realpath(array_shift($argv));
+
+    if (!isset($SCMS[$rtype])) {
+      usage("Invalid repo type $rtype");
+    }
+
+    switch ($rtype) {
+      case 'hg':
+        if (!is_dir("$rpath/.hg")) {
+          usage("Repo path must be a local hg repo dir");
+        }
+        break;
+      case 'git':
+        if (!is_dir("$rpath/.git")) {
+          usage("Repo path must be a local git repo dir");
+        }
+        break;
+      case 'svn':
+        if (!file_exists("$rpath/format")) {
+          usage("Repo path must be a svn repo");
+        }
+        break;
+      default:
+        usage("Invalid repo type $rtype");
+    }
+
+    $repos[$rname] = array($rname, $rtype, $rpath);
+    continue;
+  }
+
+  if ($arg == '--link') {
+    if (count($argv) < 3) {
+      usage("Missing arguments to --link");
+    }
+    $pname = array_shift($argv);
+    $rname = array_shift($argv);
+    $rloc = array_shift($argv);
+
+    $links[] = array($pname, $rname, $rloc);
+    $projects[$pname] = $pname;
+    continue;
+  }
+
+  if ($arg == '--vardir') {
+    if (count($argv) < 1) {
+      usage("Missing argument to --vardir");
+    }
+    $vardir = array_shift($argv);
+    continue;
+  }
+
+  if ($arg == '--config-file') {
+    if (count($argv) < 1) {
+      usage("Missing argument to --config-file");
+    }
+    $config_file_name = array_shift($argv);
+    continue;
+  }
+
+  if ($arg == '--dsn') {
+    if (count($argv) < 1) {
+      usage("Missing argument to --dsn");
+    }
+    $DSN = array_shift($argv);
+    continue;
+  }
+
+  $args[] = $arg;
+}
+
+if (count($args)) {
+  usage("Unhandled arguments");
+}
+
+if (file_exists("$vardir/mtrac.db")) {
+  echo "Nothing to do (already configured)\n";
+  exit(1);
+}
+
+
+echo "Setting up mtrack with:\n";
+
+echo "Projects:\n  ";
+echo join("\n  ", $projects);
+echo "\n\n";
+
+echo "Repos:\n";
+foreach ($repos as $repo) {
+  echo "  " . join(" ", $repo) . "\n";
+  foreach ($links as $link) {
+    if ($link[1] == $repo[0]) {
+      echo "    $link[2] -> $link[0]\n";
+    }
+  }
+}
+echo "\n";
+
+if (count($tracs)) {
+  foreach ($tracs as $tname => $pname) {
+    echo "Import trac $name -> $pname\n";
+  }
+}
+
+function usage($msg = '')
+{
+  echo $msg, <<<TXT
+
+
+Usage: init
+
+  --wiki-type              specify the repo type to use when initializing wiki
+                           Supported repo types are listed below.
+                           To use a pre-existing wiki, don't use this option,
+                           use --repo wiki instead.
+
+  --repo {name} {type} {repopath}
+                           define a repo named {name} of {type} at {repopath}
+  --link {project} {repo} {location}
+                           link a repo location to a project
+  --trac {project} {tracenv}
+                           import data from a trac environment at {tracenv}
+                           and associate with project {project}
+
+  --vardir {dir}           where to store database and search engine state.
+                           Defaults to "var" in the current directory; will
+                           be created if it does not exist.
+
+  --config-file {filename} Where to create the configuration file.
+                           defaults to config.ini in the current directory.
+
+  --author-alias {filename}
+                           where to find an authors file that maps usernames.
+                           This is used to initialize the canonicalizations
+                           used by the system.  The format is a file of the
+                           form: sourcename=canonicalname
+                           The import will replace all instances of sourcename
+                           with canonicalname in the history, and will record
+                           the mapping so that future items will respect it.
+
+  --author-info {filename}
+                           Where to find a file that will be used to initialize
+                           the userinfo table. The format is:
+                           canonid,fullname,email,active,timezone
+                           where canonid is the canonical username.
+
+  --dsn {dsn}
+                           If specified, should be a compatible PDO DSN
+                           locating the database to store the mtrack state.
+                           If you want to use sqlite, simply omit this
+                           parameter.  If you want to use PostgreSQL, then
+                           you should enter a string like:
+                           pgsql:host=dbhostname
+
+                           mtrack only supports SQLite and PostgreSQL in
+                           this version.
+
+
+Supported repo types:
+
+
+TXT;
+
+  foreach (MTrackRepo::getAvailableSCMs() as $t => $r) {
+    $d = $r->getSCMMetaData();
+    printf("  %10s   %s\n", $t, $d['name']);
+  }
+  echo "\n\n\n";
+
+  exit(1);
+}
+
+if (!is_dir($vardir)) {
+  mkdir($vardir);
+  chmod($vardir, 02777);
+}
+if (!is_dir("$vardir/attach")) {
+  mkdir("$vardir/attach");
+  chmod("$vardir/attach", 02777);
+}
+
+putenv("MTRACK_CONFIG_FILE=" . $config_file_name);
+if (!file_exists($config_file_name)) {
+  /* create a new config file */
+  $CFG = file_get_contents("config.ini.sample");
+  $CFG = str_replace("@VARDIR@", realpath($vardir), $CFG);
+  if (count($projects)) {
+    list($pname) = array_keys($projects);
+  } else {
+    $pname = "mtrack demo";
+  }
+  $CFG = str_replace("@PROJECT@", $pname, $CFG);
+  if ($DSN == null) {
+    $DSN = "sqlite:@{core:dblocation}";
+  }
+  $CFG = str_replace("@DSN@", "\"$DSN\"", $CFG);
+
+  $tools_to_find = array('diff', 'diff3', 'php', 'svn', 'hg',
+    'git', 'svnserve', 'svnlook', 'svnadmin');
+  foreach ($SCMS as $S) {
+    $m = $S->getSCMMetaData();
+    if (isset($m['tools'])) {
+      foreach ($m['tools'] as $t) {
+        $tools_to_find[] = $t;
+      }
+    }
+  }
+
+  /* find reasonable defaults for tools */
+  $tools = array();
+  foreach ($tools_to_find as $toolname) {
+    foreach (explode(PATH_SEPARATOR, getenv('PATH')) as $pdir) {
+      if (DIRECTORY_SEPARATOR == '\\' &&
+          file_exists($pdir . DIRECTORY_SEPARATOR . $toolname . '.exe')) {
+        $tools[$toolname] = $pdir . DIRECTORY_SEPARATOR . $toolname . '.exe';
+        break;
+      } else if (file_exists($pdir . DIRECTORY_SEPARATOR . $toolname)) {
+        $tools[$toolname] = $pdir . DIRECTORY_SEPARATOR . $toolname;
+        break;
+      }
+    }
+    if (!isset($tools[$toolname])) {
+      // let the system find it in the path at runtime
+      $tools[$toolname] = $toolname;
+    }
+  }
+  $toolscfg = '';
+  foreach ($tools as $toolname => $toolpath) {
+    $toolscfg .= "$toolname = \"$toolpath\"\n";
+  }
+  $CFG = str_replace("@TOOLS@", $toolscfg, $CFG);
+  file_put_contents($config_file_name, $CFG);
+}
+unset($_GLOBALS['MTRACK_CONFIG_SKIP_BOOT']);
+MTrackConfig::$ini = null;
+MTrackDB::$db = null;
+MTrackTicket_CustomFields::$me = null;
+MTrackConfig::boot();
+
+include dirname(__FILE__) . '/schema-tool.php';
+
+if (file_exists("$vardir/mtrac.db")) {
+  chmod("$vardir/mtrac.db", 0666);
+}
+
+$db = MTrackDB::get();
+
+# if the config has custom fields, or the runtime config from an earlier
+# installation does, let's update the schema, if needed.
+MTrackTicket_CustomFields::getInstance()->save();
+
+MTrackChangeset::$use_txn = false;
+$db->beginTransaction();
+
+$CANON_USERS = array();
+if ($aliasfile) {
+  foreach (file($aliasfile) as $line) {
+    if (preg_match('/^\s*([^=]+)\s*=\s*(.*)\s*$/', $line, $M)) {
+      if (!strlen($M[1])) {
+        continue;
+      }
+      $CANON_USERS[$M[1]] = $M[2];
+    }
+  }
+}
+
+foreach ($CANON_USERS as $src => $dest) {
+  MTrackDB::q('insert into useraliases (alias, userid) values (?, ?)',
+    $src, strlen($dest) ? $dest : null);
+}
+
+if ($authorfile) {
+  foreach (file($authorfile) as $line) {
+    $author = explode(',', trim($line));
+    if (strlen($author[0])) {
+      MTrackDB::q('insert into userinfo (
+        userid, fullname, email, active, timezone) values
+        (?, ?, ?, ?, ?)',
+        $author[0],
+        $author[1],
+        $author[2],
+        ((int)$author[3]) ? 1 : 0,
+        $author[4]);
+    }
+  }
+}
+
+/* set up initial ACL tree structure */
+$rootobjects = array(
+  'Reports', 'Browser', 'Wiki', 'Timeline', 'Roadmap', 'Tickets',
+  'Enumerations', 'Components', 'Projects', 'User', 'Snippets',
+);
+
+foreach ($rootobjects as $rootobj) {
+  MTrackACL::addRootObjectAndRoles($rootobj);
+}
+
+# Add forking permissions
+$ents = MTrackACL::getACL('Browser', false);
+$ents[] = array('BrowserCreator', 'fork', true);
+$ents[] = array('BrowserForker', 'fork', true);
+$ents[] = array('BrowserForker', 'read', true);
+MTrackACL::setACL('Browser', false, $ents);
+
+$CS = MTrackChangeset::begin('~setup~', 'initial setup');
+
+foreach ($projects as $pname) {
+  $p = new MTrackProject;
+  $p->shortname = $pname;
+  $p->name = $pname;
+  $p->save($CS);
+  $projects[$pname] = $p;
+}
+
+foreach ($repos as $repo) {
+  $r = new MTrackRepo;
+  $r->shortname = $repo[0];
+  $r->scmtype = $repo[1];
+  $r->repopath = $repo[2];
+
+  foreach ($links as $link) {
+    list($pname, $rname, $loc) = $link;
+    if ($rname == $r->shortname) {
+      $p = $projects[$pname];
+      $r->addLink($p, $loc);
+    }
+  }
+
+  $r->save($CS);
+  $repos[$r->shortname] = $r;
+}
+
+if (!isset($repos['wiki'])) {
+  // Set up the wiki repo (if they don't already have one named wiki)
+
+  if ($wiki_repo_type === null) {
+    $wiki_repo_type = MTrackConfig::get('tools', 'hg');
+    if (file_exists($wiki_repo_type)) {
+      $wiki_repo_type = 'hg';
+    } else {
+      $wiki_repo_type = 'svn';
+    }
+  }
+
+  $r = new MTrackRepo;
+  $r->shortname = 'wiki';
+  $r->scmtype = $wiki_repo_type;
+  $r->repopath = realpath($vardir) . DIRECTORY_SEPARATOR . 'wiki';
+  $r->description = 'The mtrack wiki pages are stored here';
+  echo " ** Creating repo 'wiki' of type $r->scmtype to hold Wiki content at $r->repopath\n";
+  echo " ** (use --repo option to specify an alternate location)\n";
+  echo " ** (use --wiki-type option to specify an alternate type)\n";
+  $r->save($CS);
+  $repos['wiki'] = $r;
+
+  $r->reconcileRepoSettings();
+}
+
+
+foreach (glob("defaults/wiki/*") as $filename) {
+  $name = basename($filename);
+  echo "wiki: $name\n";
+
+  $w = MTrackWikiItem::loadByPageName($name);
+  if ($name == 'WikiStart' && $w !== null) {
+    /* skip existing WikiStart, as it may have been customized */
+    continue;
+  }
+  if ($w === null) {
+    $w = new MTrackWikiItem($name);
+  }
+
+  $w->content = file_get_contents($filename);
+  $w->save($CS);
+}
+touch("$vardir/.initializing");
+MTrackWikiItem::commitNow();
+
+foreach (glob("defaults/reports/*") as $filename) {
+  $name = basename($filename);
+  echo "report: $name\n";
+
+  $rep = new MTrackReport;
+  $rep->summary = $name;
+
+  list($sql, $wiki) = explode("\n\n", file_get_contents($filename), 2);
+
+  $rep->description = $wiki;
+  $rep->query = $sql;
+  $rep->save($CS);
+}
+if (count($tracs) == 0) {
+  // Default enumerations
+  foreach (array('defect', 'enhancement', 'task') as $v => $c) {
+    $cl = new MTrackClassification;
+    $cl->name = $c;
+    $cl->value = $v;
+    $cl->save($CS);
+  }
+  foreach (array('fixed', 'invalid', 'wontfix', 'duplicate', 'worksforme')
+      as $v => $c) {
+    $cl = new MTrackResolution;
+    $cl->name = $c;
+    $cl->value = $v;
+    $cl->save($CS);
+  }
+  foreach (array('blocker', 'critical', 'major', 'normal', 'minor', 'trivial')
+      as $v => $c) {
+    $cl = new MTrackSeverity;
+    $cl->name = $c;
+    $cl->value = $v;
+    $cl->save($CS);
+  }
+  foreach (array('highest', 'high', 'normal', 'low', 'lowest')
+      as $v => $c) {
+    $cl = new MTrackPriority;
+    $cl->name = $c;
+    $cl->value = $v;
+    $cl->save($CS);
+  }
+  foreach (array('new', 'open', 'closed', 'reopened')
+      as $v => $c) {
+    $cl = new MTrackTicketState;
+    $cl->name = $c;
+    $cl->value = $v;
+    $cl->save($CS);
+  }
+}
+$CS->commit();
+
+$i = 0;
+foreach ($tracs as $tracdb => $pname) {
+  import_from_trac($projects[$pname], $tracdb, $i++);
+}
+echo "Committing\n"; flush();
+$db->commit();
+echo "Done\n";
+unlink("$vardir/.initializing");
diff --git a/bin/make-authorized-keys.php b/bin/make-authorized-keys.php
new file mode 100644 (file)
index 0000000..ef65108
--- /dev/null
@@ -0,0 +1,73 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (function_exists('date_default_timezone_set')) {
+  date_default_timezone_set('UTC');
+}
+
+include dirname(__FILE__) . '/../inc/common.php';
+
+# Our purpose is to generate an appropriately formatted authorized_keys2
+# file.  We should be run as the user that will own the authorized_keys2
+# file.
+
+$codeshell = escapeshellcmd(realpath(dirname(__FILE__) . '/codeshell'));
+$config = escapeshellarg(realpath(MTrackConfig::getLocation()));
+$mtrack = escapeshellarg(realpath(dirname(__FILE__) . '/..'));
+
+$keyfile = MTrackConfig::get('repos', 'authorized_keys2');
+if (!$keyfile) {
+  echo "You need to set [repos] authorized_keys2\n";
+  exit(1);
+}
+$fp = fopen($keyfile . ".new", 'w');
+
+$users_with_keys = array();
+
+foreach (MTrackDB::q('select userid, sshkeys from userinfo where sshkeys is not null')->fetchAll(PDO::FETCH_OBJ) as $u) {
+  $user = escapeshellarg($u->userid);
+  $lines = preg_split("/\r?\n/", $u->sshkeys);
+  foreach ($lines as $key) {
+    $users_with_keys[$u->userid] = $u->userid;
+    $key = trim($key);
+    if (!strlen($key)) continue;
+    fwrite($fp, "command=\"$codeshell $config $user $mtrack\",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty $key\n");
+  }
+}
+
+fclose($fp);
+chmod("$keyfile.new", 0755);
+rename("$keyfile.new", $keyfile);
+
+# Unfortunately, subversion doesn't allow us to hook authorization requests
+# over svnserve, so we need to pre-compute access to each svn repo for each
+# user that can access it.  With very large numbers of svn repos or large
+# numbers of users, this will be "expensive".
+$fp = null;
+$authzname = MTrackConfig::get('core', 'vardir') . '/svn.authz';
+
+foreach (MTrackDB::q("select repoid from repos where scmtype = 'svn'")
+    ->fetchAll(PDO::FETCH_COLUMN, 0) as $repoid) {
+  $R = MTrackRepo::loadById($repoid);
+  if (!$fp) {
+    $fp = fopen("$authzname.new", 'w');
+    # deny all
+    fwrite($fp, "[/]\n* =\n");
+  }
+  fwrite($fp, "[" . $R->getBrowseRootName() . ":/]\n");
+  foreach ($users_with_keys as $user) {
+    MTrackAuth::su($user);
+    $level = '';
+    if (MTrackACL::hasAllRights("repo:$repoid", 'commit')) {
+      $level = 'rw';
+    } elseif (MTrackACL::hasAllRights("repo:$repoid", 'checkout')) {
+      $level = 'r';
+    }
+    MTrackAuth::drop();
+    if (strlen($level)) {
+      fwrite($fp, "$user = $level\n");
+    }
+  }
+}
+fclose($fp);
+rename("$authzname.new", $authzname);
diff --git a/bin/modify.php b/bin/modify.php
new file mode 100644 (file)
index 0000000..d80f9b2
--- /dev/null
@@ -0,0 +1,181 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (!file_exists("bin/init.php")) {
+  echo "You must run me from the top-level mtrack dir\n";
+  exit(1);
+}
+
+/* People doing this are not necessarily sane, make sure we have PDO and
+ * pdo_sqlite */
+if (!extension_loaded('PDO') || !extension_loaded('pdo_sqlite')) {
+  echo "Mtrack requires PDO and pdo_sqlite to function\n";
+  exit(1);
+}
+
+$projects = array();
+$repos = array();
+$tracs = array();
+$links = array();
+$config_file_name = 'config.ini';
+
+$args = array();
+array_shift($argv);
+while (count($argv)) {
+  $arg = array_shift($argv);
+
+  if ($arg == '--config-file') {
+    if (count($argv) < 1) {
+      usage("Missing argument to --config-file");
+    }
+    $config_file_name = array_shift($argv);
+    continue;
+  }
+  if ($arg == '--trac') {
+    if (count($argv) < 2) {
+      usage("Missing arguments to --trac");
+    }
+    $pname = array_shift($argv);
+    $tracdb = array_shift($argv);
+
+    if (!file_exists($tracdb)) {
+      usage("Tracdb path must be a sqlite database");
+    }
+    $tracs[$tracdb] = $pname;
+    $projects[$pname] = $pname;
+    continue;
+  }
+  if ($arg == '--repo') {
+    if (count($argv) < 3) {
+      usage("Missing arguments to --repo");
+    }
+    $rname = array_shift($argv);
+    $rtype = array_shift($argv);
+    $rpath = array_shift($argv);
+
+    switch ($rtype) {
+      case 'hg':
+        if (!is_dir("$rpath/.hg")) {
+          usage("Repo path must be an hg repo dir");
+        }
+        break;
+      case 'svn':
+        if (!file_exists("$rpath/format")) {
+          usage("Repo path must be a svn repo");
+        }
+        break;
+      default:
+        usage("Invalid repo type $rtype");
+    }
+
+    $repos[$rname] = array($rname, $rtype, $rpath);
+    continue;
+  }
+
+  if ($arg == '--link') {
+    if (count($argv) < 3) {
+      usage("Missing arguments to --link");
+    }
+    $pname = array_shift($argv);
+    $rname = array_shift($argv);
+    $rloc = array_shift($argv);
+
+    $links[] = array($pname, $rname, $rloc);
+    $projects[$pname] = $pname;
+    continue;
+  }
+
+  $args[] = $arg;
+}
+
+if (count($args)) {
+  usage("Unhandled arguments");
+}
+
+putenv("MTRACK_CONFIG_FILE=" . $config_file_name);
+
+require_once 'inc/common.php';
+include 'bin/import-trac.php';
+
+MTrackACL::$batch = true;
+MTrackSearchDB::setBatchMode();
+
+$db = MTrackDB::get();
+MTrackChangeset::$use_txn = false;
+$db->beginTransaction();
+
+$CS = MTrackChangeset::begin('~modify~', 'setup modified');
+
+foreach ($projects as $pname) {
+  $p = MTrackProject::loadByName($pname);
+  if ($p === null) {
+    $p = new MTrackProject;
+    $p->shortname = $pname;
+    $p->name = $pname;
+    $p->save($CS);
+  }
+  $projects[$pname] = $p;
+}
+
+foreach ($repos as $repo) {
+  $r = new MTrackRepo;
+  $r->shortname = $repo[0];
+  $r->scmtype = $repo[1];
+  $r->repopath = $repo[2];
+
+  foreach ($links as $link) {
+    list($pname, $rname, $loc) = $link;
+    if ($rname == $r->shortname) {
+      $p = $projects[$pname];
+      $r->addLink($p, $loc);
+    }
+  }
+
+  $r->save($CS);
+  $repos[$r->shortname] = $r;
+}
+
+$CS->commit();
+
+$i = 0;
+foreach ($tracs as $tracdb => $pname) {
+  import_from_trac($projects[$pname], $tracdb, true);
+}
+echo "Updating ACL tree\n"; flush();
+MTrackACL::applyBatch();
+echo "Committing\n"; flush();
+$db->commit();
+MTrackSearchDB::optimize();
+echo "Done\n";
+
+function usage($msg = '')
+{
+  require_once 'inc/common.php';
+  echo $msg, <<<TXT
+
+Usage: modify
+  --repo {name} {type} {repopath}
+                           define a repo named {name} of {type} at {repopath}
+  --link {project} {repo} {location}
+                           link a repo location to a project
+  --trac {project} {tracenv}
+                           import data from a trac environment at {tracenv}
+                           and associate with project {project}
+
+  --config-file {filename} Where to find the configuration file.
+                           defaults to config.ini in the current directory.
+
+
+Supported repo types:
+
+
+TXT;
+
+  foreach (MTrackRepo::getAvailableSCMs() as $t => $r) {
+    $d = $r->getSCMMetaData();
+    printf("  %10s   %s\n", $t, $d['name']);
+  }
+  echo "\n\n\n";
+
+  exit(1);
+}
diff --git a/bin/schema-tool.php b/bin/schema-tool.php
new file mode 100644 (file)
index 0000000..4d8bd2b
--- /dev/null
@@ -0,0 +1,131 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (function_exists('date_default_timezone_set')) {
+  date_default_timezone_set('UTC');
+}
+ini_set('memory_limit', -1);
+
+include_once dirname(__FILE__) . '/../inc/common.php';
+
+$dsn = 'sqlite::memory:';
+#$dsn = 'pgsql:dbname=wez';
+
+$dsn = MTrackConfig::get('core', 'dsn');
+if (!$dsn) {
+  $dsn = 'sqlite:' . MTrackConfig::get('core', 'dblocation');
+}
+
+if (preg_match("/^sqlite:(.*)$/", $dsn, $M)) {
+  $dbfile = $M[1];
+  if (file_exists($dbfile)) {
+    $bak = $dbfile . '.' . uniqid();
+    echo "Backing up $dbfile as $bak\n";
+    copy($dbfile, $bak);
+  }
+}
+
+$db = new PDO($dsn);
+$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+$driver = $db->getAttribute(PDO::ATTR_DRIVER_NAME);
+$adapter_class = "MTrackDBSchema_$driver";
+$adapter = new $adapter_class;
+
+$adapter->setDB($db);
+$vers = $adapter->determineVersion();
+echo "Version: ";
+var_dump($vers);
+
+$db->beginTransaction();
+MTrackDB::$db = $db;
+
+$schemata = array();
+$latest = null;
+foreach (glob(dirname(__FILE__) . '/../schema/*.xml') as $filename) {
+  $latest = new MTrackDBSchema($filename);
+  $schemata[$latest->version] = $latest;
+}
+
+if ($vers === null) {
+
+  if (true) {
+    // Fresh install
+    echo "Applying schema version $latest->version\n";
+
+    foreach ($latest->tables as $t) {
+      $adapter->createTable($t);
+    }
+    if (isset($latest->post[$driver])) {
+      $db->exec($latest->post[$driver]);
+    }
+
+    $vers = $latest->version;
+
+  } else {
+    // while developing, make it go through the whole migration
+    $initial = $schemata[0];
+    echo "Applying schema version $initial->version\n";
+
+    foreach ($initial->tables as $t) {
+      $adapter->createTable($t);
+    }
+    $vers = 0;
+  }
+}
+
+while ($vers < $latest->version) {
+  $current = $schemata[$vers];
+  $next = $schemata[$vers+1];
+
+  echo "Applying migration from schema version $current->version to $next->version\n";
+
+  $migration = dirname(__FILE__) . "/../schema/$next->version-pre.php";
+  if (file_exists($migration)) {
+    echo "Running migration script schema/$next->version-pre.php\n";
+    include $migration;
+  }
+
+  /* create any new tables */
+  foreach ($next->tables as $t) {
+    if (isset($current->tables[$t->name])) continue;
+    /* doesn't yet exist, so create it! */
+    $adapter->createTable($t);
+  }
+
+  /* modify existing tables */
+  foreach ($current->tables as $t) {
+    if (!isset($next->tables[$t->name])) continue;
+
+    $nt = $next->tables[$t->name];
+    /* compare; have they changed? */
+    if (!$t->sameAs($nt)) {
+      $adapter->alterTable($t, $nt);
+    }
+  }
+
+  /* delete dead tables */
+  foreach ($current->tables as $t) {
+    if (isset($next->tables[$t->name])) continue;
+    $adapter->dropTable($t);
+  }
+
+  $vers++;
+
+  if (isset($next->post[$driver])) {
+    $db->exec($next->post[$driver]);
+  }
+
+  $migration = dirname(__FILE__) . "/../schema/$vers.php";
+  if (file_exists($migration)) {
+    echo "Running migration script schema/$vers.php\n";
+    include $migration;
+  }
+}
+
+$db->exec('delete from mtrack_schema');
+$q = $db->prepare('insert into mtrack_schema (version) values (?)');
+$q->execute(array($latest->version));
+$db->commit();
+
+
diff --git a/bin/send-notifications.php b/bin/send-notifications.php
new file mode 100644 (file)
index 0000000..df8f801
--- /dev/null
@@ -0,0 +1,829 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (function_exists('date_default_timezone_set')) {
+  date_default_timezone_set('UTC');
+}
+
+include dirname(__FILE__) . '/../inc/common.php';
+
+// Force this to be the configure value or something that will guide it
+// to be set
+$ABSWEB = MTrackConfig::get('core', 'weburl');
+if (!strlen($ABSWEB)) {
+  $ABSWEB = "(configure [core] weburl in config.ini)";
+}
+$vardir = MTrackConfig::get('core', 'vardir');
+
+$DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
+$NO_MAIL = strlen(getenv('DEBUG_NOMAIL')) ? true : false;
+
+$MAX_DIFF = 200 * 1024;
+$USE_BATCHING = false;
+
+if (!$DEBUG) {
+  /* only allow one instance to run concurrently */
+  $lockfp = fopen($vardir . '/.notifier.lock', 'w');
+  if (!$lockfp) {
+    exit(1);
+  }
+  if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
+    echo "Another instance is already running\n";
+    exit(1);
+  }
+  /* "leak" $lockfp, so that the lock is held while we continue to run */
+}
+
+$db = MTrackDB::get();
+
+// default to the last 10 minutes, but prefer the last recorded run time
+$last = MTrackDB::unixtime(time() - 600);
+foreach (MTrackDB::q('select last_run from last_notification')->fetchAll()
+    as $row) {
+  $last = $row[0];
+}
+$LATEST = strtotime($last);
+if (getenv('DEBUG_TIME')) {
+  $dtime = strtotime(getenv('DEBUG_TIME'));
+  if ($dtime > 0) {
+    $LATEST = $dtime;
+    $last = MTrackDB::unixtime($LATEST);
+    echo "Using $last as last time (specified via DEBUG_TIME var)\n";
+  }
+}
+
+class CanonicalLineEndingFilter extends php_user_filter {
+  function filter($in, $out, &$consumed, $closing)
+  {
+    while ($bucket = stream_bucket_make_writeable($in)) {
+      $bucket->data = preg_replace("/\r?\n/", "\r\n", $bucket->data);
+      $consumed += $bucket->datalen;
+      stream_bucket_append($out, $bucket);
+    }
+    return PSFS_PASS_ON;
+  }
+}
+class UnixLineEndingFilter extends php_user_filter {
+  function filter($in, $out, &$consumed, $closing)
+  {
+    while ($bucket = stream_bucket_make_writeable($in)) {
+      $bucket->data = preg_replace("/\r?\n/", "\n", $bucket->data);
+      $consumed += $bucket->datalen;
+      stream_bucket_append($out, $bucket);
+    }
+    return PSFS_PASS_ON;
+  }
+}
+stream_filter_register("mtrackcanonical", 'CanonicalLineEndingFilter');
+stream_filter_register("mtrackunix", 'UnixLineEndingFilter');
+
+$watched = MTrackWatch::getWatchedItemsAndWatchers($last, 'email');
+printf("Got %d watchers\n", count($watched));
+
+/* For each watcher, compute the changes.
+ * Group changes by ticket, sending one email per ticket.
+ * Group tickets into batch updates if the only fields that changed are
+ * bulk update style (milestone, assignment etc.)
+ *
+ * For the wiki repo, group by file so that serial edits within the batch
+ * period show up as a single email.
+ */
+
+foreach ($watched as $user => $objects) {
+  $udata = MTrackAuth::getUserData($user);
+
+  foreach ($objects as $object => $items) {
+    list($otype, $oid) = explode(':', $object, 2);
+
+    $fname = "notify_$otype";
+    if (function_exists($fname)) {
+      call_user_func($fname, $object, $oid, $items, $user, $udata);
+    } else {
+      echo "WARN: no notifier for $otype $oid\n";
+    }
+    foreach ($items as $o) {
+      if ($o instanceof MTrackSCMEvent) {
+        $t = strtotime($o->ctime);
+      } else {
+        $t = strtotime($o->changedate);
+      }
+      if ($t > $LATEST) {
+        $LATEST = $t;
+      }
+    }
+  }
+}
+
+function get_change_audit($items)
+{
+  $cid_list = array();
+  $all_cs = array();
+
+  foreach ($items as $obj) {
+    if (!($obj instanceof MTrackSCMEvent)) {
+      $all_cs[$obj->cid] = $obj;
+      if (!isset($obj->audit)) {
+        $obj->audit = array();
+        $cid_list[] = $obj->cid;
+      }
+    }
+  }
+
+  if (count($cid_list)) {
+    $cid_list = join(',', $cid_list);
+    foreach (MTrackDB::q("select * from change_audit where cid in ($cid_list)")
+        ->fetchAll(PDO::FETCH_OBJ) as $aud) {
+      $cid = $aud->cid;
+      unset($aud->cid);
+      $all_cs[$cid]->audit[] = $aud;
+    }
+  }
+
+  return $all_cs;
+}
+
+function compute_contributor($items)
+{
+  $contributors = array();
+  foreach ($items as $obj) {
+    if (isset($obj->who)) {
+      $contributors[$obj->who]++;
+    } elseif (isset($obj->changeby)) {
+      $contributors[$obj->changeby]++;
+    }
+  }
+  $count = 0;
+  $major = null;
+  foreach ($contributors as $user => $input) {
+    if ($input > $count) {
+      $major = $user;
+      $count = $input;
+    }
+  }
+  unset($contributors[$major]);
+
+  $res = array();
+  $res[] = array($major, MTrackAuth::getUserData($major));
+  foreach ($contributors as $user => $input) {
+    $res[] = array($user, MTrackAuth::getUserData($user));
+  }
+
+  return $res;
+}
+
+function encode_header($string)
+{
+  $result = array();
+  foreach (preg_split("/\s+/", $string) as $portion) {
+    if (!preg_match("/[\x80-\xff]/", $portion)) {
+      $result[] = $portion;
+      continue;
+    }
+
+    $result[] = '=?UTF-8?B?' . base64_encode($portion) . '?=';
+  }
+  return join(' ', $result);
+}
+
+function make_email($uname, $uinfo)
+{
+  $email = $uinfo['email'];
+  $name = $uinfo['fullname'];
+  if ($name == $email) {
+    return $email;
+  }
+  return encode_header($name) . " <$email>";
+}
+
+function _sort_mx($A, $B)
+{
+  $diff = $A->weight - $B->weight;
+  if ($diff) return $diff;
+  return strncmp($A->host, $B->host);
+}
+
+function get_weighted_mx($domain)
+{
+  static $cache = array();
+
+  if (preg_match("/^\d+\.\d+\.\d+\.\d+$/", $domain)) {
+    /* IP literal */
+    $mx = new stdclass;
+    $mx->host = $domain;
+    $mx->a = array($domain);
+    $cache[$domain] = array($mx);
+    return $cache[$domain];
+  }
+
+  /* ensure that we don't things as local */
+  $domain = rtrim($domain, '.') . '.';
+
+  if (isset($cache[$domain])) {
+    return $cache[$domain];
+  }
+
+  if (!getmxrr($domain, $hosts, $weight)) {
+    // Fallback to A
+    $mx = new stdclass;
+    $mx->host = $domain;
+    $mx->a = gethostbynamel($domain);
+    $cache[$domain] = array($mx);
+    return $cache[$domain];
+  }
+  $res = array();
+  foreach ($hosts as $i => $host) {
+    $mx = new stdclass;
+    $mx->host = $host;
+    $mx->weight = $weight[$i];
+    $mx->a = gethostbynamel("$host.");
+    $res[] = $mx;
+  }
+  usort($res, '_sort_mx');
+
+  $cache[$domain] = $res;
+  return $cache[$domain];
+}
+
+$smtp_cache = array();
+
+function smtp_cmd($fp, $cmd, $exp = 250)
+{
+  global $smtp_cache;
+  global $DEBUG;
+
+  $res = array();
+
+  if ($DEBUG) {
+    echo "> $cmd";
+  }
+  fwrite($fp, $cmd);
+  do {
+    $line = fgets($fp);
+    $res[] = $res;
+    if ($DEBUG) {
+      echo "< $line";
+    }
+  } while ($line[3] == '-');
+  $code = (int)$line;
+  if ($code != $exp) {
+    foreach ($smtp_cache as $k => $v) {
+      if ($v === $fp) {
+        unset($smtp_cache[$k]);
+      }
+    }
+    throw new Exception("got $code, expected $exp");
+  }
+  return $res;
+}
+
+function smtp_connect($rcpt)
+{
+  global $DEBUG;
+
+  list($local, $domain) = explode('@', $rcpt);
+  global $smtp_cache;
+  if (isset($smtp_cache[$domain])) {
+    return $smtp_cache[$domain];
+  }
+
+  $smarthost = MTrackConfig::get('notify', 'smtp_relay');
+  if ($smarthost) {
+    $domain = $smarthost;
+  }
+  $mxs = get_weighted_mx($domain);
+
+  foreach ($mxs as $ent) {
+    foreach ($ent->a as $addr) {
+      $fp = stream_socket_client("$addr:25", $e, $s);
+      if ($fp) {
+        do {
+          $banner = fgets($fp);
+          if ($DEBUG) {
+            echo "< $banner";
+          }
+        } while ($banner[3] == '-');
+        $code = (int)$banner;
+        if ($code != 220) {
+          fclose($fp);
+          continue;
+        }
+        smtp_cmd($fp, sprintf("EHLO %s\r\n", php_uname('n')));
+        $smtp_cache[$domain] = $fp;
+        return $fp;
+      }
+    }
+  }
+  return false;
+}
+
+function send_mail($rcpt, $payload)
+{
+  global $DEBUG;
+  global $NO_MAIL;
+
+  $reciplist = escapeshellarg($rcpt);
+  if ($DEBUG) {
+    echo "would mail: $reciplist\n\n";
+    echo stream_get_contents($payload);
+    rewind($payload);
+  }
+  if ($NO_MAIL) {
+    echo "Not sending any mail\n";
+    return;
+  }
+  if (function_exists('getmxrr') &&
+      MTrackConfig::get('notify', 'use_smtp')) {
+    /* let's do some SMTP */
+    echo "Using SMTP\n";
+
+    $fp = smtp_connect($rcpt);
+    if ($fp) {
+      $local = MTrackConfig::get('notify', 'smtp_from');
+      if (!$local) {
+        $local = php_uname('n');
+      }
+      smtp_cmd($fp, "MAIL FROM:<$local>\r\n");
+      smtp_cmd($fp, "RCPT TO:<$rcpt>\r\n");
+      smtp_cmd($fp, "DATA\r\n", 354);
+
+      while ($line = fgets($payload)) {
+        // Session transparency
+        if ($line[0] == '.') {
+          $line = '.' . $line;
+        }
+        // Canonical line endings
+        $line = preg_replace("/\r?\n/", "\r\n", $line);
+        if ($DEBUG) {
+          echo "> $line";
+        }
+        fwrite($fp, $line);
+      }
+      smtp_cmd($fp, ".\r\n");
+    }
+  } else {
+    echo "Using sendmail\n";
+    $pipe = popen("/usr/sbin/sendmail $reciplist", 'w');
+    stream_filter_append($pipe, 'mtrackunix', STREAM_FILTER_WRITE);
+    stream_copy_to_stream($payload, $pipe);
+    pclose($pipe);
+  }
+}
+
+function notify_repo($object, $tid, $items, $user, $udata)
+{
+  global $ABSWEB;
+
+  $revlist = array();
+  $repo = null;
+
+  $code_by_repo = array();
+  foreach ($items as $obj) {
+    if (!($obj instanceof MTrackSCMEvent)) {
+      if (!isset($obj->ent)) {
+        continue;
+      }
+      $obj = $obj->ent;
+    }
+
+    $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
+    $revlist[] = $obj->rev;
+    if ($repo === null) {
+      $repo = $obj->repo;
+    }
+  }
+  if (!count($code_by_repo)) {
+    return;
+  }
+
+  $reponame = $repo->getBrowseRootName();
+
+  $from = compute_contributor($items);
+
+  $headers = array(
+    'MIME-Version' => '1.0',
+    'Content-Type' => 'text/plain; charset="UTF-8"',
+    'Content-Transfer-Encoding' => 'quoted-printable',
+  );
+
+  $headers['To'] = make_email($user, $udata);
+  $headers['From'] = make_email($from[0][0], $from[0][1]);
+  if (count($from) > 1) {
+    $rep = array();
+    array_shift($from);
+    foreach ($from as $email) {
+      $rep[] = make_email($email[0], $email[1]);
+    }
+    $headers['Reply-To'] = join(', ', $rep);
+  }
+  $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n');
+  $headers['Message-ID'] = "<$mid>";
+
+  /* find related project(s) */
+  $projects = array();
+  foreach ($items as $obj) {
+    if (!isset($obj->_related)) continue;
+    foreach ($obj->_related as $rel) {
+      if ($rel[0] == 'project') {
+        $p = get_project($rel[1]);
+        $projects[$p->projid] = $p->shortname;
+      }
+    }
+  }
+  if (count($projects)) {
+    natsort($projects);
+    $subj = "[" . join($projects) . "] ";
+    $headers['X-mtrack-project-list'] = join(' ', $projects);
+    foreach ($projects as $pname) {
+      $headers["X-mtrack-project-$pname"] = $pname;
+      $headers['X-mtrack-project'][] = $pname;
+    }
+  } else {
+    $subj = '';
+  }
+  $subj = sprintf("%scommit %s ", $subj, $reponame);
+  foreach ($revlist as $rev) {
+    if (strlen($subj) > 72) break;
+    $subj .= " [$rev]";
+  }
+  $headers['Subject'] = $subj;
+
+  global $ABSWEB;
+
+  $plain = tmpfile();
+  stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
+  foreach ($headers as $name => $value) {
+    if (is_array($value)) {
+      foreach ($value as $v) {
+        fprintf($plain, "%s: %s\n", $name, encode_header($v));
+      }
+    } else {
+      fprintf($plain, "%s: %s\n", $name, encode_header($value));
+    }
+  }
+
+  fprintf($plain, "\n");
+  fflush($plain);
+  add_qp_filter($plain);
+
+  generate_repo_changes($plain, $code_by_repo, true);
+
+  rewind($plain);
+
+  send_mail($udata['email'], $plain);
+}
+
+function add_qp_filter($stream)
+{
+  stream_filter_append($stream, 'convert.quoted-printable-encode',
+    STREAM_FILTER_WRITE, array(
+      'line-length' => 74,
+      'line-break-chars' => "\r\n",
+    )
+  );
+}
+
+function notify_ticket($object, $tid, $items, $user, $udata)
+{
+  global $MAX_DIFF;
+  $T = MTrackIssue::loadById($tid);
+  if (!is_object($T)) {
+    echo "Failed to load ticket by id: $tid\n";
+    return;
+  }
+
+  $from = compute_contributor($items);
+  $audit = get_change_audit($items);
+
+  $comments = array();
+  $fields = array();
+  $field_changers = array();
+  $old_values = array();
+  $is_initial = false;
+
+  foreach ($audit as $CS) {
+    if ($CS->cid == $T->created) {
+      // We use this to set a Message-ID header
+      $is_initial = true;
+    }
+    foreach ($CS->audit as $aud) {
+      // fieldname is of the form: "ticket:id:fieldname"
+      $field = substr($aud->fieldname, strlen($object)+1);
+
+      if ($field == '@comment') {
+        $comments[] = "Comment by " .
+            $CS->who . ":\n" . $aud->value;
+      } elseif ($field != 'spent') {
+        $field_changers[$field] = $CS->who;
+        if (!isset($old_values[$field])) {
+          $old_values[$field] = $aud->oldvalue;
+        }
+      }
+    }
+  }
+
+
+  $headers = array(
+    'MIME-Version' => '1.0',
+    'Content-Type' => 'text/plain; charset="UTF-8"',
+    'Content-Transfer-Encoding' => 'quoted-printable',
+  );
+
+  $headers['To'] = make_email($user, $udata);
+  $headers['From'] = make_email($from[0][0], $from[0][1]);
+  if (count($from) > 1) {
+    $rep = array();
+    array_shift($from);
+    foreach ($from as $email) {
+      $rep[] = make_email($email[0], $email[1]);
+    }
+    $headers['Reply-To'] = join(', ', $rep);
+  }
+  $mid = $T->tid . '@' . php_uname('n');
+  if ($is_initial) {
+    $headers['Message-ID'] = "<$mid>";
+  } else {
+    $headers['Message-ID'] = "<$T->updated.$mid>";
+    $headers['In-Reply-To'] = "<$mid>";
+    $headers['References'] = "<$mid>";
+  }
+  /* find related project(s) */
+  $projects = array();
+  foreach ($items as $obj) {
+    if (!isset($obj->_related)) continue;
+    foreach ($obj->_related as $rel) {
+      if ($rel[0] == 'project') {
+        $p = get_project($rel[1]);
+        $projects[$p->projid] = $p->shortname;
+      }
+    }
+  }
+  if (count($projects)) {
+    natsort($projects);
+    $subj = "[" . join($projects, ' ') . "] ";
+
+    $headers['X-mtrack-project-list'] = join(' ', $projects);
+    foreach ($projects as $pname) {
+      $headers["X-mtrack-project-$pname"] = $pname;
+      $headers['X-mtrack-project'][] = $pname;
+    }
+  } else {
+    $subj = '';
+  }
+
+  $headers['Subject'] = sprintf("%s#%s %s (%s %s)",
+    $subj, $T->nsident, $T->summary, $T->status, $T->classification);
+
+  global $ABSWEB;
+
+  $plain = tmpfile();
+  stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
+  foreach ($headers as $name => $value) {
+    if (is_array($value)) {
+      foreach ($value as $v) {
+        fprintf($plain, "%s: %s\n", $name, encode_header($v));
+      }
+    } else {
+      fprintf($plain, "%s: %s\n", $name, encode_header($value));
+    }
+  }
+  fprintf($plain, "\n");
+  fflush($plain);
+  add_qp_filter($plain);
+
+  fprintf($plain, "%sticket.php/%s\n\n", $ABSWEB, $T->nsident);
+
+  fprintf($plain, "#%s: %s (%s %s)\n",
+    $T->nsident, $T->summary, $T->status, $T->classification);
+
+  $owner = strlen($T->owner) ? $T->owner : 'nobody';
+  fprintf($plain, "Responsible: %s (%s / %s)\n",
+    $owner, $T->priority, $T->severity);
+
+  fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones()));
+  fprintf($plain, "Component: %s\n", join(', ', $T->getComponents()));
+
+  fprintf($plain, "\n");
+
+  // Display changed fields grouped by the person that last changed them
+  $who_changed = array();
+  foreach ($field_changers as $field => $who) {
+    $who_changed[$who][] = $field;
+  }
+  foreach ($who_changed as $who => $fieldlist) {
+    fprintf($plain, "Changes by %s:\n", $who);
+
+    foreach ($fieldlist as $field) {
+      $old = $old_values[$field];
+
+      if (!strlen($old) && $field == 'nsident') {
+        continue;
+      }
+
+      $value = null;
+      switch ($field) {
+        case '@components':
+          $old = array();
+          foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
+            if (!strlen($id)) continue;
+            $c = get_component($id);
+            $old[$id] = $c->name;
+          }
+          $value = $T->getComponents();
+          $field = 'Component';
+          break;
+        case '@milestones':
+          $old = array();
+          foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
+            if (!strlen($id)) continue;
+            $m = get_milestone($id);
+            $old[$id] = $m->name;
+          }
+          $value = array();
+          $value = $T->getMilestones();
+          $field = 'Milestone';
+          break;
+        case '@keywords':
+          $old = array();
+          $field = 'Keywords';
+          $value = $T->getKeywords();
+          break;
+        default:
+          $old = null;
+          $value = $T->{$field};
+      }
+      if (is_array($value)) {
+        $value = join(', ', $value);
+      }
+      if (is_array($old)) {
+        $old = join(', ', $old);
+      }
+      if ($value == $old) {
+        continue;
+      }
+      if ($field == 'description') {
+        $lines = count(explode("\n", $old));
+        $diff = mtrack_diff_strings($old, $value);
+        $diff_add = 0;
+        $diff_rem = 0;
+        foreach (explode("\n", $diff) as $line) {
+          if ($line[0] == '-') {
+            $diff_rem++;
+          } else if ($line[0] == '+') {
+            $diff_add++;
+          }
+        }
+        if (abs($diff_add - $diff_rem) > $lines / 2) {
+          fprintf($plain, "Description changed to:\n%s\n\n", $value);
+        } else {
+          fprintf($plain, "Description changed:\n%s\n\n", $diff);
+        }
+      } else {
+        fprintf($plain, "%s %s -> %s\n", $field, $old, $value);
+      }
+    }
+  }
+  foreach ($comments as $comment) {
+    fprintf($plain, "\n%s\n", $comment);
+  }
+
+  $code_by_repo = array();
+  foreach ($items as $obj) {
+    if (!($obj instanceof MTrackSCMEvent)) {
+      if (!isset($obj->ent)) {
+        continue;
+      }
+      $obj = $obj->ent;
+    }
+    $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
+  }
+  generate_repo_changes($plain, $code_by_repo);
+
+  fprintf($plain, "\n%sticket.php/%s\n\n", $ABSWEB, $T->nsident);
+  rewind($plain);
+
+  send_mail($udata['email'], $plain);
+}
+
+function generate_repo_changes($plain, $code_by_repo, $changelog = false)
+{
+  global $MAX_DIFF;
+  global $ABSWEB;
+
+  foreach ($code_by_repo as $reponame => $ents) {
+    fprintf($plain, "\nChanges in %s:\n", $reponame);
+
+    /* Gather up affected files */
+    $files = array();
+    foreach ($ents as $obj) {
+      foreach ($obj->files as $file) {
+        $files[$file->name][$file->status]++;
+      }
+    }
+    ksort($files);
+    $n = 0;
+    fprintf($plain, "  Affected files:\n");
+    foreach ($files as $filename => $status) {
+      if ($n++ > 20) {
+        fprintf($plain, "  ** More than 20 files were changed\n");
+        break;
+      }
+      fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename);
+    }
+
+    $too_big = false;
+    foreach ($ents as $obj) {
+      fprintf($plain, "\n[%s] by %s\n", $obj->rev, $obj->changeby);
+      fprintf($plain, "%schangeset.php/%s/%s\n\n",
+        $ABSWEB, $reponame, $obj->rev);
+
+      if ($changelog) {
+        fprintf($plain, "%s\n\n", $obj->changelog);
+      }
+
+      $email_size = get_stream_size($plain);
+      if ($email_size >= $MAX_DIFF) {
+        $too_big = true;
+        continue;
+      }
+      foreach ($obj->files as $file) {
+        $diff = get_diff($obj, $file);
+
+        $email_size = get_stream_size($plain);
+        $diff_size = get_stream_size($diff);
+
+        if ($email_size + $diff_size < $MAX_DIFF) {
+          stream_copy_to_stream($diff, $plain);
+          fwrite($plain, "\n");
+        } else {
+          $too_big = true;
+        }
+      }
+
+    }
+    if ($too_big) {
+      fprintf($plain, "  * Diff exceeds configured limit\n");
+    }
+  }
+}
+
+function get_stream_size($stm)
+{
+  $st = fstat($stm);
+  return $st['size'];
+}
+
+function get_diff(MTrackSCMEvent $ent, $file)
+{
+  $fname = $file->name;
+  if (isset($ent->__diff[$fname])) {
+    $diff = $ent->__diff[$fname];
+    rewind($diff);
+    return $diff;
+  }
+  $tmp = tmpfile();
+  $diff = $ent->repo->diff($file, $ent->rev);
+  stream_copy_to_stream($diff, $tmp);
+  $ent->__diff[$fname] = $tmp;
+  rewind($tmp);
+  return $tmp;
+}
+
+function get_project($pid) {
+  static $projects = array();
+  if (isset($projects[$pid])) {
+    return $projects[$pid];
+  }
+  $projects[$pid] = MTrackProject::loadById($pid);
+  return $projects[$pid];
+}
+
+function get_component($cid) {
+  static $comps = array();
+  if (isset($comps[$cid])) {
+    return $comps[$cid];
+  }
+  $comps[$cid] = MTrackComponent::loadById($cid);
+  return $comps[$cid];
+}
+
+function get_milestone($mid) {
+  static $comps = array();
+  if (isset($comps[$mid])) {
+    return $comps[$mid];
+  }
+  $comps[$mid] = MTrackMilestone::loadById($mid);
+  return $comps[$mid];
+}
+
+if (!$DEBUG) {
+  // Now we are done, update the last run time
+  $db->beginTransaction();
+  $db->exec("delete from last_notification");
+  $t = MTrackDB::unixtime($LATEST);
+  echo "updating last run to $t $LATEST\n";
+  $db->exec("insert into last_notification (last_run) values ('$t')");
+  $db->commit();
+}
+
+mtrack_cache_maintain();
+
diff --git a/bin/setup b/bin/setup
new file mode 100755 (executable)
index 0000000..102b779
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+if test -z "$PHP" ; then
+       PHP=`which php`
+fi
+
+if test ! -x "$PHP" ; then
+       echo "Could not find PHP; please install PHP 5.2 or later"
+       exit 1
+fi
+
+if test ! -f bin/setup ; then
+       echo "You must run me from the top-level mtrack dir"
+       exit 1
+fi
+
+exec $PHP bin/init.php $*
diff --git a/bin/setup.bat b/bin/setup.bat
new file mode 100644 (file)
index 0000000..c7deffa
--- /dev/null
@@ -0,0 +1,26 @@
+@ECHO OFF
+REM This is the windows equivalent of the setup bash script in this directory
+
+REM Please change this variable to point to your php.exe location, please do not place quotation marks around the path
+SET PHP_BIN=C:\Program Files\PHP\php.exe
+SET PROJ_NAM=
+SET REPO_NAME=
+SET REPO_PATH=
+SET REPO_TYPE=svn
+
+REM Do not edit beyond this point
+
+IF EXIST %CD%/init.php GOTO CDONEUP
+IF EXIST %CD%/init.php GOTO CDONEUP
+IF EXIST %CD%/bin/init.php GOTO RUN
+
+ECHO "Error: cannot find bin\init.php, you can double click on setup.bat to run me properly"
+
+:CDONEUP
+CD ../
+
+:RUN
+
+CALL "%PHP_BIN%" "%CD%\bin\init.php" --repo %REPO_NAME% %REPO_TYPE% %REPO_PATH%
+   --link %PROJ_NAME% %REPO_NAME% /%*
+PAUSE
\ No newline at end of file
diff --git a/bin/solr-schema.xml b/bin/solr-schema.xml
new file mode 100644 (file)
index 0000000..1fdb55e
--- /dev/null
@@ -0,0 +1,85 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!--
+This is a Solr schema for mtrack.
+Copy or adapt this into your Solr configuration.
+-->
+<schema name="mtrack" version="1.2">
+  <types>
+    <fieldType name="string" class="solr.StrField"
+      sortMissingLast="true" omitNorms="true"/>
+    <fieldType name="date" class="solr.TrieDateField"
+      omitNorms="true" precisionStep="0" positionIncrementGap="0"/>
+    <fieldType name="text" class="solr.TextField"
+        positionIncrementGap="100">
+      <analyzer type="index">
+        <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+        <filter class="solr.StopFilterFactory" ignoreCase="true"
+          words="stopwords.txt" enablePositionIncrements="true"
+          />
+        <filter class="solr.WordDelimiterFilterFactory"
+          generateWordParts="1"
+          generateNumberParts="1"
+          catenateWords="1"
+          catenateNumbers="1"
+          catenateAll="0"
+          splitOnCaseChange="1"/>
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.SnowballPorterFilterFactory"
+          language="English"
+          protected="protwords.txt"/>
+      </analyzer>
+      <analyzer type="query">
+        <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+        <filter class="solr.SynonymFilterFactory"
+          synonyms="synonyms.txt"
+          ignoreCase="true"
+          expand="true"/>
+        <filter class="solr.StopFilterFactory" ignoreCase="true"
+          words="stopwords.txt" enablePositionIncrements="true"/>
+        <filter class="solr.WordDelimiterFilterFactory"
+          generateWordParts="1"
+          generateNumberParts="1"
+          catenateWords="0"
+          catenateNumbers="0"
+          catenateAll="0"
+          splitOnCaseChange="1"/>
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.SnowballPorterFilterFactory"
+          language="English" protected="protwords.txt"/>
+      </analyzer>
+    </fieldType>
+  </types>
+  <fields>
+    <!-- unique id -->
+    <field name="id" type="string" indexed="true"
+      stored="true" required="true"/>
+
+    <!-- populated with the time this field was indexed -->
+    <field name="indexed" type="date" indexed="true"
+      stored="true" default="NOW" multiValued="false"/>
+
+    <field name="date" type="date" indexed="true" stored="true"/>
+    <field name="created" type="date" indexed="true" stored="true"/>
+
+    <field name="who" type="string" indexed="true" stored="true"/>
+    <field name="creator" type="string" indexed="true" stored="true"/>
+    <field name="owner" type="string" indexed="true" stored="true"/>
+
+    <field name="description" type="text" index="true"
+      stored="true" multiValued="true"/>
+    <field name="all" type="text" index="true" stored="true"
+      multiValued="true"/>
+    <dynamicField name="*" type="text" index="true"
+      stored="true" multiValued="true"/>
+
+  </fields>
+
+  <uniqueKey>id</uniqueKey>
+  <defaultSearchField>all</defaultSearchField>
+  <copyField source="*" dest="all"/>
+  <solrQueryParser defaultOperator="OR"/>
+
+</schema>
+<!-- vim:ts=2:sw=2:et
+-->
+
diff --git a/bin/svn-commit-hook b/bin/svn-commit-hook
new file mode 100755 (executable)
index 0000000..6e756c2
--- /dev/null
@@ -0,0 +1,91 @@
+#!/usr/bin/env php
+<?php # vim:ts=2:sw=2:et:ft=php:
+/* For licensing and copyright terms, see the file named LICENSE */
+// called as:
+// svn-commit-hook what svnrepopath svntxn [mtrackconfig]
+
+$action = $argv[1];
+$svnrepo = $argv[2];
+$svntxn = $argv[3];
+
+if (isset($argv[4])) {
+  putenv("MTRACK_CONFIG_FILE=" . $argv[4]);
+}
+
+if ($action == 'pre') {
+  $svntxn = "-t $svntxn";
+} else {
+  $svntxn = "-r $svntxn";
+}
+
+include dirname(__FILE__) . '/../inc/common.php';
+if (file_exists(MTrackConfig::get('core', 'vardir') . '/.initializing')) {
+  exit(0);
+}
+
+
+class SvnCommitHookBridge implements IMTrackCommitHookBridge {
+  var $repo;
+  var $svnlook;
+  var $svnrepo;
+  var $svntxn;
+
+  function __construct($repo, $svnrepo, $svntxn) {
+    $this->repo = $repo;
+    $this->svnlook = MTrackConfig::get('tools', 'svnlook');
+    $this->svnrepo = $svnrepo;
+    $this->svntxn = $svntxn;
+  }
+
+  function enumChangedOrModifiedFileNames() {
+    $files = array();
+    $fp = popen("$this->svnlook changed $this->svntxn $this->svnrepo", 'r');
+    while (($line = fgets($fp)) !== false) {
+      if (preg_match("/^(\w)\s+(.*)$/", trim($line), $M)) {
+        $action = $M[1];
+        $path = $M[2];
+        if ($action == 'A' || $action == 'U' || $action == 'UU') {
+          $files[] = $path;
+        }
+      }
+    }
+    return $files;
+  }
+
+  function getCommitMessage() {
+    $fp = popen("$this->svnlook log $this->svntxn $this->svnrepo", 'r');
+    $log = stream_get_contents($fp);
+    $log = preg_replace('/\[(\d+)\]/',
+      "[changeset:" . $this->repo->getBrowseRootName() . ",\$1]", $log);
+    return $log;
+  }
+
+  function getFileStream($path) {
+    return popen(
+      "$this->svnlook cat $this->svntxn $this->svnrepo $path", 'r');
+  }
+
+  function getChangesetDescriptor() {
+    $rev = trim(str_replace('-r ', '', $this->svntxn));
+    return '[changeset:' . $this->repo->getBrowseRootName() . ",$rev]";
+  }
+}
+
+try {
+  $repo = MTrackRepo::loadByLocation($svnrepo);
+  $bridge = new SvnCommitHookBridge($repo, $svnrepo, $svntxn);
+  $author = trim(shell_exec("$bridge->svnlook author $svntxn $svnrepo"));
+  $author = mtrack_canon_username($author);
+  MTrackAuth::su($author);
+  $checker = new MTrackCommitChecker($repo);
+  if ($action == 'pre') {
+    $checker->preCommit($bridge);
+  } else {
+    $checker->postCommit($bridge);
+  }
+  exit(0);
+} catch (Exception $e) {
+  fwrite(STDERR, "\n" . $e->getMessage() . "\n\n ** Commit failed [$action]\n");
+  exit(1);
+}
+
diff --git a/bin/update-search-index.php b/bin/update-search-index.php
new file mode 100644 (file)
index 0000000..e9d686a
--- /dev/null
@@ -0,0 +1,159 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+if (function_exists('date_default_timezone_set')) {
+  date_default_timezone_set('UTC');
+}
+
+include dirname(__FILE__) . '/../inc/common.php';
+MTrackSearchDB::setBatchMode();
+
+$vardir = MTrackConfig::get('core', 'vardir');
+
+/* only allow one instance to run concurrently */
+$fp = fopen("$vardir/.indexer.lock", 'w');
+if (!$fp) {
+  exit(1);
+}
+if (!flock($fp, LOCK_EX|LOCK_NB)) {
+  echo "Another instance is already running\n";
+  exit(1);
+}
+/* "leak" $fp, so that the lock is held while we continue to run */
+
+/* log to a file in the var dir */
+function log_output($buffer)
+{
+  global $log_file;
+  fwrite($log_file, $buffer);
+  fflush($log_file);
+}
+$log_file = fopen("$vardir/indexer.log", 'w');
+if ($log_file) {
+  ob_start('log_output');
+}
+function log_flush() {
+  flush();
+  ob_flush();
+  flush();
+}
+
+$start_time = time();
+echo "Indexing started at " . date('c') . "\n";
+log_flush();
+
+$last = '1990-01-01T00:00:00';
+$ALL = true;
+foreach (MTrackDB::q('select last_run from search_engine_state')->fetchAll()
+    as $row) {
+  $last = $row[0];
+  $ALL = false;
+}
+$LATEST = strtotime($last);
+$FIRST = $LATEST;
+$ITEMS = 0;
+$DONE = array();
+
+function index_and_measure($object)
+{
+  global $DONE;
+  if (isset($DONE[$object])) {
+    return true;
+  }
+  $DONE[$object] = true;
+
+  echo "Examine: $object\n";
+  log_flush();
+  $start = time();
+  $res = MTrackSearchDB::index_object($object);
+  $elapsed = time() - $start;
+  printf("Indexed $object in %f seconds\n", $elapsed);
+  log_flush();
+  return $res;
+}
+
+function index_items($lower)
+{
+  global $LATEST;
+  global $ITEMS;
+  global $start_time;
+  global $DONE;
+  global $FIRST;
+
+  /* do the work here */
+
+  foreach (MTrackDB::q('select object, max(changedate) from changes where changedate > ? group by object order by max(changedate)', $lower)->fetchAll(PDO::FETCH_NUM)
+      as $row) {
+
+    if ($LATEST > ($FIRST + 3) && time() - $start_time > 280) {
+      // Step back 1 second on the next run, otherwise we may miss out
+      // a couple of items from the current second
+      $LATEST--;
+      break;
+    }
+
+    list($object, $when) = $row;
+
+    if (true) {
+      $ITEMS++;
+      $res = index_and_measure($object);
+    } else {
+      $res = true;
+    }
+    if ($res === false) {
+      echo "Don't know how to index $object\n";
+    } else {
+      echo "Processed $object $when > $lower\n";
+    }
+    $t = strtotime($when);
+    if ($t > $LATEST) {
+      $LATEST = $t;
+    }
+  }
+}
+
+if ($ALL) {
+  // walk all the wiki pages, in case someone checked in against the
+  // wiki repo outside of the app
+  $repo = null;
+  $root = MTrackWikiItem::getRepoAndRoot($repo);
+  $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
+  function walk_wiki($repo, $dir, $suf)
+  {
+    global $DONE;
+
+    $items = $repo->readdir($dir);
+    foreach ($items as $file) {
+      if ($file->is_dir) {
+        walk_wiki($repo, $file->name, $suf);
+      } else {
+        if (!strlen($suf) || substr($file->name, -strlen($suf)) == $suf) {
+          //echo "Going to index wiki:$file->name\n";
+          $object = "wiki:$file->name";
+          index_and_measure($object);
+        } else {
+          //echo "NO: wiki:$file->name\n";
+        }
+      }
+    }
+  }
+  walk_wiki($repo, $root, $suf);
+}
+
+index_items($last);
+
+$db = MTrackDB::get();
+$db->beginTransaction();
+$db->exec("delete from search_engine_state");
+$insert = $db->prepare("insert into search_engine_state (last_run) values (?)");
+$insert->execute(array(MTrackDB::unixtime($LATEST)));
+$db->commit();
+
+if ($ITEMS > 0) {
+  MTrackSearchDB::commit();
+}
+
+$end_time = time();
+$elapsed = $end_time - $start_time;
+echo "$ITEMS items processed (in $elapsed seconds)\n";
+
diff --git a/config.ini.sample b/config.ini.sample
new file mode 100644 (file)
index 0000000..fdc8875
--- /dev/null
@@ -0,0 +1,118 @@
+; vim:ts=2:sw=2:et:ft=dosini:
+; This file is parsed subject to the following rules:
+;
+; Unquoted tokens on the right hand side of an equals sign that correspond to
+; constants defined in PHP are replaced by the value of that constant.
+;
+; Values for the form ${name} are substituted with the value of the
+; corresponding PHP configuration directive, or if none is found, the
+; corresponding environmental variable value.
+;
+; Values of the form @{section:myname} are substituted with the value of the
+; option defined in this configuration file (for example, the "myname" value
+; in the "section" section).
+
+; Core configuration options
+[core]
+vardir = @VARDIR@
+dblocation = "@{core:vardir}/mtrac.db"
+dsn = @DSN@
+searchdb = "@{core:vardir}/search.db"
+projectname = @PROJECT@
+timezone = EST
+; mimetype_detect = fileinfo or mimemagic or file - if empty will attempt to detect which one to use or bails
+; projectlogo = image url to show in nav bar
+; default.repo = name of default repo when constructing changeset links
+; weburl = URL (including trailing slash) of canonical home of this instance.  used when sending notification email.
+; default_email_domain = domain name to use for notification mail when the user is not present in the userinfo table
+; includes = comma separated list of files to include; supports plugins
+;debug.footer = 1
+; Use .wiki to indicate a wiki filename as distinct from a wiki dir
+wikifilenamesuffix=.wiki
+; Fresh installs start in "admin party" mode, which means that any user
+; accessing the system from the loopback (127.0.0.1) is treated as admin.
+admin_party = true
+; Which search engine to use. Lucene works out of the box, but Solr has
+; better performance and higher quality results.
+; MTrackSearchEngineLucene or MTrackSearchEngineSolr
+search_engine = MTrackSearchEngineLucene
+
+[repos]
+; If true, permit the creation and forking of per-user repositories
+allow_user_repo_creation = true
+; Where per-user repositories should be created (both git and hg)
+basedir = "@{core:vardir}/repos"
+
+; The SSH user@host which can be used to access repos. You must have configured
+; SSH access as described in the mtrack SSH installation documentation
+;serverurl = "code@example.com"
+
+; The URL over which users can clone, push, pull via HG.
+; You need to configure this URL yourself using your choice of Mercurial server.
+; This is ignored if you have set serverurl.
+;hg.serverurl = "http://example.com/hg"
+;
+; The URL over which users can clone, push, pull via GIT.
+; You need to configure this URL yourself using your choice of Git server.
+; This is ignored if you have set serverurl.
+;git.serverurl = "http://example.com/git"
+
+[solr]
+; How to find your Solr instance if you're using the Apache Solr search
+; engine implementation (search_engine = MTrackSearchEngineLucene)
+url = "http://localhost:8983/solr"
+
+[ticket]
+default.classification = defect
+default.severity = normal
+default.priority = normal
+
+[notify]
+; Should we use SMTP directly?
+; Requires PHP with getmxrr functionality (not Windows on PHP < 5.3)
+use_smtp = false
+; If using SMTP, identifies a smart host via which mail will be routed.
+; Otherwise, we'll try to lookup the MX records via DNS.
+smtp_relay = "127.0.0.1"
+; If using SMTP, sets the envelope from
+smtp_from = "noreply@example.com"
+
+; Defines some basic, reasonable, permission sets for 3 classes of user.
+; These are used in addition to whatever is selected by auth plugins
+[user_class_roles]
+anonymous = ReportViewer,BrowserViewer,WikiViewer,TimelineViewer,RoadmapViewer,TicketViewer
+authenticated = ReportViewer,BrowserViewer,WikiCreator,TimelineViewer,RoadmapViewer,TicketCreator,UserViewer,SnippetCreator,BrowserForker
+admin = ReportCreator,BrowserCreator,WikiCreator,TimelineViewer,RoadmapCreator,TicketCreator,EnumerationCreator,ComponentCreator,ProjectCreator,UserCreator,SnippetCreator,BrowserForker
+
+; Explicitly place certain users in certain user classes.  This is used mainly
+; to provide a means to indicate that particular users are classed as admins.
+; The auth module group assignments are the recommended way to go for more
+; powerful/flexible group/role assignment
+[user_classes]
+
+; use the tools section to override the location of certain
+; tools, in case you have multiples or in case they live outside
+; of the standard locations
+[tools]
+@TOOLS@
+; hg = /usr/local/bin/hg
+; svn = /opt/msys/3rdParty/bin/svn
+; svnlook = /opt/msys/3rdParty/bin/svnlook
+; php = /opt/msys/3rdParty/bin/php
+; diff3 = /opt/msys/3rdParty/bin/diff3
+; diff = /opt/msys/3rdParty/bin/diff
+
+[nav:mainnav]
+; If you want to turn off the wiki navigation link (does not disable the wiki,
+; just hides the link), uncomment the following line (the empty right hand 
+; side deletes the link keyed by the left hand side)
+; /wiki.php =
+; If you want to add in other links, you can do so by adding the link on the
+; left hand side of the equals and the label on the right hand side
+
+[plugins]
+; MTrackAuth_HTTP = /Users/wez/Sites/svn.htgroup, /Users/wez/Sites/svn.htpasswd
+; MTrackCommitCheck_NoEmptyLogMessage =
+; MTrackCommitCheck_RequiresTimeReference =
+; MTrackCaptcha_Recaptcha = public, private, userclasses
+; MTrackAuth_OpenID =
diff --git a/defaults/help/ConfigIni b/defaults/help/ConfigIni
new file mode 100644 (file)
index 0000000..ed0e790
--- /dev/null
@@ -0,0 +1,181 @@
+= config.ini =
+
+The configuration file defines an mtrack instance.  mtrack will look for this
+file by first inspecting the {{{$MTRACK_CONFIG_FILE}}} environmental variable.
+If it is not set it will default to looking for {{{config.ini}}} in the mtrack
+source directory.
+
+{{{config.ini}}} is parsed using the following rule:
+
+ * {{{[name]}}} indicates that the following values belong to the ''name'' section.  You may switch section multiple times in the file if you wish.
+ * Lines beginning with a semicolon {{{;}}} character are comments and are ignored by the parser
+ * values are specified by lines of the form {{{name = value}}}.  The value belongs to the previously indicated section.
+ * Unquoted tokens on the right hand size of an equals sign are replaced by the value of a matching PHP constant
+ * Values of the form {{{${name}}}} are substituted with the value of the corresponding PHP configuration directive, or if none is found, the corresponding environmental variable value
+ * Values of the form {{{@{section:myname}}}} are substituted with the value of the option defined in this configuration file.  For example, the ''myname'' value in the ''section'' section)
+
+== [core] == #core
+
+The following options are defined for the {{{core}}} section.
+
+  vardir::
+    The location of the {{{var}}} directory, which holds all of the mtrack
+       runtime state
+
+  dblocation::
+       Where the mtrack sqlite database can be found.  This is usually defined
+       to be {{{"@{core:vardir}/mtrac.db"}}} which means that it lives in the
+       {{{var}}} directory.
+
+  searchdb::
+       Where the mtrack full-text search database can be found.  This is usually
+       defined to be {{{"@{core:vardir}/search.db"}}} which means that it lives in
+       the {{{var}}} directory.
+
+  projectname::
+       The name of the mtrack instance.  This is displayed in the top left of
+       the navigation area if the ''projectlogo'' is not defined.
+
+  timezone::
+       The default timezone to use when rendering dates.
+
+  projectlogo::
+       Specifies an URL that will be used in an image tag displayed in the top
+       left of the navigation area.
+
+  weburl::
+       Specifies the canonical URL (including trailing slash) for this mtrack
+       instance.  This is used when generating links in notification email,
+       but will also be used when generating links in the web application.
+
+  default.repo::
+       Specifies the shortname of the repo to use when generating changeset
+       links that don't otherwise specify one.  You only need this when you
+       have multiple repos.  mtrack will default to the first repo.
+
+  default_email_domain::
+       Domain name to use when inferring the email address for users that do
+       not have an email address configured in the userinfo table.
+
+  includes::
+       Comma separated list of files to be included.  The intended use is for
+       loading plugins without modifying the mtrack code.
+
+== [ticket] == #ticket
+
+  default.classification::
+       When creating a new ticket, specifies which classification to pre-select
+
+  default.severity::
+       When creating a new ticket, specifies which severity to pre-select
+
+  default.priority::
+       When creating a new ticket, specifies which priority to pre-select
+
+== [user_class_roles] == #user_class_roles
+
+This section allows you to define classes of users.  Unauthenticated users are
+placed in the ''anonymous'' user class.  Authenticated users are placed in the
+''authenticated'' user class.
+
+The names in this section define user classes, and their corresponding values
+define a list of rights that are granted to users that are in that class.
+
+The default configuration for this section is reproduced below:
+
+{{{
+; Defines some basic, reasonable, permission sets for 3 classes of user.
+; These are used in addition to whatever is selected by auth plugins
+[user_class_roles]
+anonymous = ReportViewer,BrowserViewer,WikiViewer,TimelineViewer,RoadmapViewer,TicketViewer
+authenticated = ReportViewer,BrowserViewer,WikiCreator,TimelineViewer,RoadmapViewer,TicketCreator
+admin = ReportCreator,BrowserCreator,WikiCreator,TimelineViewer,RoadmapCreator,TicketCreator,EnumerationCreator,ComponentCreator,ProjectCreator
+}}}
+
+This give anonymous users read-only access to the major areas of mtrack.
+Authenticated users are given write access to the major areas.
+
+This also defines a class called ''admin'' that has full access to all areas of mtrack.
+
+== [user_classes] == #user_classes
+
+This names in this section correspond to user names.  The value is the user class that is explicitly assigned to that user.
+
+For example:
+
+{{{
+[user_classes]
+wez = admin
+}}}
+
+places the ''wez'' user in to the ''admin'' user class.  When combined with the
+above [help:ConfigIni#user_class_roles user_class_roles] causes ''wez'' to
+belong to each of the groups associated with the ''admin'' class and thus have
+full access to the system.
+
+Configuring user_classes is not necessary if you are using an authentication
+scheme where you control which groups are assigned to the users.
+
+== [tools] == #tools
+
+The tools section controls where mtrack finds the various command line tools
+that it may need to run.
+
+The names in this section are the tool names and the value is the path to the
+tool itself.  [help:bin/Init bin/init.php] will try to populate these
+automatically when it runs, so you will not usually need to make changes here
+unless you have an alternate version of a given that is not in a standard
+location.
+
+== ![nav:mainnav] == #nav:mainnav
+
+If you want to turn off, rename or add navigation links you can do so by
+making changes to this section.
+
+The names in this section correspond to the URL of one of the navigation links
+and the value is the displayed text.
+
+To remove the wiki link from navigation:
+
+{{{
+[nav:mainnav]
+/wiki.php =
+}}}
+
+To rename the wiki link:
+
+{{{
+[nav:mainnav]
+/wiki.php = Awesome Wiki
+}}}
+
+To add a new navigation item:
+
+{{{
+[nav:mainnav]
+http://bitbucket.org/wez/mtrack/ = mtrack home
+}}}
+
+== [plugins] == #plugins
+
+mtrack has a simple plugin system.  After a plugin has been installed, it needs
+to be configured by adding an entry to this section of the configuration file.
+
+The names in this section correspond to the names of the plugin classes.  The value is interpreted as a comma separated list of strings that will be passed as arguments to the constructor of that class.
+
+For example:
+
+{{{
+[plugins]
+MTrackAuth_HTTP = /Users/wez/Sites/svn.htgroup, /Users/wez/Sites/svn.htpasswd
+}}}
+
+this will cause mtrack to run the equivalent of the following php code:
+
+{{{
+$obj = new MTrackAuth_HTTP(
+       '/Users/wez/Sites/svn.htgroup',
+       '/Users/wez/Sites/svn.htpasswd');
+}}}
+
+For more information about plugins, see [help:Plugins].
diff --git a/defaults/help/Install b/defaults/help/Install
new file mode 100644 (file)
index 0000000..44bb699
--- /dev/null
@@ -0,0 +1,371 @@
+{{{
+#!comment
+This page is formatted using wiki markup.  You may find it easier to
+run through the Quick Install steps and then navigate to help.php/Install
+and continue reading.
+}}}
+
+= Installing mtrack =
+
+== Pre-requisites ==
+
+ * A unix style operating system, such as Linux, Solaris, OS/X, FreeBSD etc.
+ * PHP 5.2, both the standalone CLI executable and Web Server (such as Apache) versions
+   * PHP must have PDO and pdo_sqlite support
+   * fileinfo or mime_magic support is recommended
+ * The {{{diff}}} and {{{diff3}}} command line tools
+ * Subversion command line tools ({{{svn}}} and {{{svnlook}}}) for Subversion repo support
+ * Mercurial command line tools ({{{hg}}}) for Mercurial repo support
+ * Access to the ''cron'' mechanism or equivalent on your system to schedule background tasks
+ * The {{{sendmail}}} command line tool for change notification emails
+
+== Quick Install ==
+
+It is recommended that you read this guide in full before installing, but if
+you're impatient and want to see it running very quickly, you can follow these
+steps.  They are intentionally terse; if you want more detail, read this guide
+in full!
+
+Note that if you want to import data from Trac, you will need to start over
+with the initialization.
+
+You should treat the quick install as a way to make a quick assessment about
+mtrack before beginning your migration in earnest.
+
+{{{
+% cd $MTRACK
+% php bin/init.php
+}}}
+
+ * configure your webserver so that the $MTRACK/web dir is accessible
+ * turn off magic_quotes_gpc
+
+You can now visit mtrack as an anonymous user and continue reading this
+document by navigating to help.php/Install.
+
+To do anything interesting, you will need to configure authentication.
+
+== Background ==
+
+An mtrack installation is defined in terms of the mtrack configuration file
+{{{config.ini}}}, which contains system settings, and the application files,
+which contain the program logic and that can be shared between between
+instances so that multiple mtrack projects don't need to have their own copies
+of the application files.
+
+mtrack uses the environment variable {{{MTRACK_CONFIG_FILE}}} to locate the
+{{{config.ini}}}, so sharing the same mtrack codebase across multiple projects
+is just a matter of ensuring that the environment is correctly set for each
+project.
+
+== Installation ==
+
+Decide where you would like the mtrack application files to reside on your
+filesystem and put them there.  mtrack itself does not place any restrictions
+on location, although the recommendation is that you do not place it in a path
+where any of the parent directories have spaces in their names.
+
+The {{{web}}} directory of the sources is intended to be the only portion
+served via your web server, and it is recommended that you configure your
+system such that the other directories are prevented from being served as a
+security precaution.
+
+You must also decide where you want to store the state for your mtrack project.
+State includes the mtrack database (which holds tickets, wiki pages and more)
+as well as attachment files and a Lucene search index.  All of these things are
+encapsulated in a {{{var}}} directory.
+
+The {{{var}}} directory ''must not'' be served via your web server.
+
+== A note on Wiki ==
+
+mtrack stores wiki pages in a repository.  By default, it will create this repo
+in the {{{var}}} directory.  If you would like to locate the wiki repo
+elsewhere, perhaps because you have want to export that and allow wiki edits to
+be made via conventional editing tools and checked back in, then you may use
+the {{{--repo}}} option to inform mtrack where it can find the existing wiki repository (you need to create it and ensure that it is accessible).
+
+
+== Performing the Installation ==
+
+From this point onwards, we use {{{$MTRACK}}} to denote the root of the mtrack
+source files, and {{{$VARDIR}}} to denote the location that you selected to
+hold the state for your mtrack project instance.  Each instance '''must''' have
+its own distinct {{{$VARDIR}}}.
+
+Each of the steps below cause {{{config.ini}}} to be created in the
+{{{$MTRACK}}} directory; you may change this by usin the {{{--config-file}}}
+option.
+
+=== Initializing ===
+
+To initialize a fresh environment that is not related to any source
+repositories:
+
+{{{
+% cd $MTRACK
+% php bin/init.php --vardir $VARDIR
+}}}
+
+However, it is quite likely that you have a source repository or two; if so,
+you will probably want to configure mtrack to see them.  You should also define
+a ''project'' and associate it with the repo; this is used later for change
+notifications.  You should initialize your instance using the following
+invocation instead:
+
+{{{
+% cd $MTRACK
+% php bin/init.php --vardir $VARDIR \
+   --repo $REPONAME svn /path/to/repo \
+   --link $PROJNAME $REPONAME /
+}}}
+
+ * $REPONAME will show up as the top level name in the source browser
+ * $PROJNAME will show up in the subject line of notification email
+ * The {{{/}}} in the {{{--link}}} line causes all changes in $REPONAME to be recognized as happening within the $PROJNAME project.  More advanced rules are possible, such as allowing multiple projects to be contained with the same repo, but are not explained here.
+
+If you are migrating from Trac, then you will want to associate your
+repository and tell mtrack to import your Trac data:
+
+{{{
+% cd $MTRACK
+% php bin/init.php --vardir $VARDIR \
+   --trac $PROJNAME /path/to/trac/environment/dir \
+   --repo $REPONAME svn /path/to/repo \
+   --link $PROJNAME $REPONAME /
+}}}
+
+If your Trac instance contains a lot of data, you might want to use the
+{{{--disable-index}}} option to improve the import speed.  This turns off
+incremental index updates during the import and trades import speed now for
+indexing speed in the indexing background job that runs later.
+
+{{{
+#!comment
+
+For Windows users - there is a setup.bat file in the /bin directory you may use
+Right clik the setup.bat file and choose edit
+Change the PHP_BIN location to the absolute path to your PHP directory.
+Change the PROJ_NAM, REPO_NAME and REPO_PATH to the desired locations
+and values.  You may also need to change REPO_TYPE if you are not setting up an
+svn environment.
+Save the changes, then exit and double click on setup.bat to initialize your environment
+
+Currently the windows batch file does not support the --trac or --vardir arguments
+If you are comfortable in a command line environment, you may open cmd.exe, cd
+to the location of setup.bat, and pass the additional arguments you desire.
+}}}
+
+=== Set the ownership on $VARDIR ===
+
+Ensure that the web server process can access the mtrack state:
+
+{{{
+# chown -R nobody:nobody $VARDIR
+}}}
+
+ * '''nobody''' must be changed to match the user account under which the web server process runs
+
+{{{
+#!comment
+ * In Windows environments, make sure the user your webserver is running as can write to $VARDIR
+   This is normally the SYSTEM user for apache2 installations and IUSR_computername for IIS installations
+   See php.net installation instructions for your version of IIS for more information
+}}}
+
+For a reference on the init script and its parameters, consult [help:bin/Init].
+
+=== Tool configuration ===
+
+Once initialized, open {{{config.ini}}} in your text editor and fill out the
+{{{[tools]}}} section so that mtrack knows the full path to the {{{svn}}},
+{{{svnlook}}} and {{{php}}} command line utilities.  These will be guessed by
+the initialization script based on what it can find your {{{$PATH}}}.
+
+{{{
+#!comment
+Windows users can find the appropriate diff tools at
+http://gnuwin32.sourceforge.net/packages/diffutils.htm Make sure to use
+absolute paths to the appropriate tools and use quotes around the values.  Note
+that if you have tortoisesvn installed you will not have the command line svn
+tools required, you'll need to install the command lines tools as well.
+
+For example, on a 64 bit system your paths will look similiar to this
+{{{
+hg = "C:\Program Files\TortoiseHg\hg.exe"
+; svn = /opt/msys/3rdParty/bin/svn
+; svnlook = /opt/msys/3rdParty/bin/svnlook
+php = "C:\Program Files (x86)\PHP\php.exe"
+diff3 = "C:\Program Files (x86)\diff\diff3.exe"
+diff = "C:\Program Files (x86)\diff\dif.exe"
+}}}
+}}}
+
+=== Cron configuration ===
+
+mtrack defers content indexing and email notifications so that they can be
+intelligently handled in batches and not intrude on the web application
+performance.
+
+Configure a cron entry to run these batch processes every 10 minutes, using the following as a template:
+
+{{{
+0,10,20,30,40,50 * * * * nice su nobody -c "php $MTRACK/bin/update-search-index.php ; php $MTRACK/bin/send-notifications.php" >/dev/null 2>/dev/null
+}}}
+
+ * '''nobody''' must be changed to match the user account under which the web server process runs
+
+You are free to change the interval to anything you like (although the system
+minimum is 1 minute); longer intervals allow more ticket changes to be
+collapsed into an email at the expense of a larger perceived lag between the
+time the event happens and the time the email is sent.
+
+If you imported a large trac instance, the initial run of
+{{{update-search-index.php}}} can take some time to run (and can tax the CPU
+while it is running).  You need not worry about this; it is normal.  Both
+{{{update-search-index.php}}} and {{{send-notifications.php}}} are intelligent
+enough to only allow 1 instance to run concurrently, so even if there is a
+backlog of work for them to process, they won't trip over each other or other
+invocations launched from cron.
+
+=== Subversion commit-hook configuration ===
+
+mtrack works best when integrated with your SCM.  There is a pre-commit hook
+that can be used to enforce commit policies (such as proper formatting of
+commit messages, or proper syntax in changed source files), and a post-commit
+hook that can be used to apply commit messages as comments to related tickets.
+
+Both the pre- and post-commit hooks are implemented by
+{{{bin/svn-commit-hook}}}.  To enable it, arrange for your pre-commit hook to
+invoke it:
+
+If you do not have an existing hook, then create the following shell script in
+the {{{hooks}}} directory of your subversion repository.  If you have an
+existing hook, then adjust it to invoke the mtrack commit hook in addition to
+the other actions it takes:
+
+{{{
+#!/bin/sh
+php $MTRACK/bin/svn-commit-hook pre $1 $2 $MTRACK/config.ini
+}}}
+
+Then make sure it is executable:
+
+{{{
+# chmod a+rx hooks/pre-commit
+}}}
+
+The post-commit hook is similar:
+
+{{{
+#!/bin/sh
+php $MTRACK/bin/svn-commit-hook post $1 $2 $MTRACK/config.ini
+}}}
+
+Then make sure it is executable:
+
+{{{
+# chmod a+rx hooks/post-commit
+}}}
+
+=== Mercurial Commit Hook ===
+
+Add this to the .hg/hgrc in the Mercurial repos:
+
+{{{
+[hooks]
+changegroup.mtrack = php $MTRACK/bin/hg-commit-hook changegroup $MTRACK/config.ini
+commit.mtrack = php $MTRACK/bin/hg-commit-hook commit $MTRACK/config.ini
+pretxncommit.mtrack = php $MTRACK/bin/hg-commit-hook pretxncommit $MTRACK/config.ini
+pretxnchangegroup.mtrack = php $MTRACK/bin/hg-commit-hook pretxnchangegroup $MTRACK/config.ini
+}}}
+
+=== Notification Email Configuration ===
+
+mtrack notifies users of changes based on the project associated with the
+source code that was changed.  During initialization, we used the {{{--link}}}
+argument to define a relationship between a location within a repo and a
+project.
+
+To enable email notification, we now need to associate an email address with a
+project.  This is done via the Administration section; you can edit the email
+address associated with the project from there.
+
+Edit {{{config.ini}}} and set the '''weburl''' to match the URL you are going
+to use for the web application.  It is important to include the trailing slash
+in the URL that you put into the configuration file.  This value is used to
+construct clickable links in notification emails.
+
+=== Authentication Considerations ===
+
+mtrack uses plugins to control authentication and authorization.  By default,
+it will respect the user identity of the command line user, but all web
+accesses will be mapped to an ''anonymous'' user account that has read-only
+access rights.
+
+The recommended authentication approach is to configure your web server to
+apply HTTP authentication to the mtrack application to secure it.
+
+mtrack ships with an {{{MTrackAuth_HTTP}}} plugin that will recognize when the
+web server has authenticated the user, and if not, will initiate Basic or
+Digest authentication itself.
+
+The default {{{config.ini}}} file leaves the HTTP auth module commented out;
+you should uncomment it and inform it where it can find apache style group and
+password files.  If the password file contains digest authentication
+credentials, the filename must be prefixed with {{{digest:}}}.
+
+{{{
+[plugins]
+MTrackAuth_HTTP = /path/to/htgroup, /path/to/htpasswd
+; for digest:
+;MTrackAuth_HTTP = /path/to/htgroup, digest:/path/to/htpasswd
+}}}
+
+ * At this time, mtrack does not ship with a mechanism to allow both unauthenticated and authenticated access (but it could be implemented pretty easily)
+
+More information on authentication can be found in [help:plugin/AuthHTTP].
+
+== Web server Configuration ==
+
+ * Configure your web server such that your preferred URL maps to the {{{$MTRACK/web}}} directory
+ * Ensure that {{{magic_quotes_gpc}}} is set to {{{Off}}} in your PHP configuration.
+
+A snippet from my httpd.conf:
+
+{{{
+# mtrack prototype
+<Location /mtrack/eng>
+       AuthType Basic
+       AuthName "Access for mtrack"
+       AuthUserFile "/path/to/htpasswd"
+       AuthGroupFile "/path/to/htgroup"
+       require group developers
+</Location>
+<Directory /home/wez/mtrack/web>
+       Options Indexes FollowSymLinks
+       AllowOverride None
+       Order allow,deny
+       Allow from all
+
+       DirectoryIndex index.php
+       php_value magic_quotes_gpc Off
+</Directory>
+Alias /mtrack/eng /home/wez/mtrack/web
+}}}
+
+You may want to consider something like this for multi-instance hosting, if your "foo" project has its vardir at {{{/data/foo}}} and your "bar" project has its vardir at {{{/data/bar}}}:
+
+{{{
+Alias /mtrack/foo /home/wez/mtrack/web
+SetEnvIf Request_URI "^/mtrack/foo(/|$)" "MTRACK_CONFIG_FILE=/data/foo/config.ini"
+Alias /mtrack/bar /home/wez/mtrack/web
+SetEnvIf Request_URI "^/mtrack/bar(/|$)" "MTRACK_CONFIG_FILE=/data/bar/config.ini"
+}}}
+
+== Done ==
+
+Your basic configuration is now complete.  There are a number of other settings
+in {{{config.ini}}} that can be adjusted (See [help:ConfigIni] for details),
+but following the steps above should be sufficient to get you up and running.
+
diff --git a/defaults/help/Introduction b/defaults/help/Introduction
new file mode 100644 (file)
index 0000000..d04b31b
--- /dev/null
@@ -0,0 +1,16 @@
+= Welcome to mtrack =
+
+This document serves as an introduction to '''mtrack'''.  If you're seeing this text show up when you click on the ''wiki'' navigation button, it is because the default WikiStart page includes this introductory text.
+
+You are free to edit any of the wiki content without fear of losing the help pages; you can always find and read them via the title index in the wiki section.
+
+== What is mtrack ? ==
+
+'''mtrack''' is an Open Source project management tool heavily inspired by the popular [http://trac.edgewall.org Trac] tool created by Edgewall Software.  '''mtrack''' is implemented in PHP and is geared towards managing issues that span multiple code repositories.
+
+'''mtrack''' is Copyright 2008-2010 [http://www.messagesystems.com/ Message Systems, Inc.] and is licensed under the terms of the [http://bitbucket.org/wez/mtrack/src/tip/LICENSE Modified BSD License].
+
+== Getting Started ==
+
+ * [help:Install Installation]
+
diff --git a/defaults/help/Links b/defaults/help/Links
new file mode 100644 (file)
index 0000000..03e0dbe
--- /dev/null
@@ -0,0 +1,291 @@
+= Links =
+
+TracLinks are a fundamental feature of Trac, because they allow easy hyperlinking between the various entities in the system—such as tickets, reports, changesets, Wiki pages, milestones, and source files—from anywhere WikiFormatting is used.
+
+TracLinks are generally of the form '''type:id''' (where ''id'' represents the
+number, name or path of the item) though some frequently used kinds of items
+also have short-hand notations.
+
+== Where to use TracLinks ==
+You can use TracLinks in:
+
+ * Source code (Subversion) commit messages
+ * Wiki pages
+ * Full descriptions for tickets, reports and milestones
+
+and any other text fields explicitly marked as supporting WikiFormatting.
+
+Some examples:
+ * Tickets: '''!#1''' or '''!ticket:1'''
+ * Ticket comments: '''!comment:1:ticket:2''' 
+ * Reports: '''!{1}''' or '''!report:1'''
+ * Changesets: '''!r1''', '''![1]''', '''!changeset:1''' or (restricted) '''![1/trunk]''', '''!changeset:1/trunk'''
+ * Revision log: '''!r1:3''', '''![1:3]''' or '''!log:@1:3''', '''!log:trunk@1:3''', '''![2:5/trunk]'''
+ * Diffs (requires [trac:milestone:0.10 0.10]): '''!diff:@1:3''', '''!diff:tags/trac-0.9.2/wiki-default//tags/trac-0.9.3/wiki-default''' or '''!diff:trunk/trac@3538//sandbox/vc-refactoring@3539'''
+ * Wiki pages: '''!CamelCase''' or '''!wiki:CamelCase'''
+ * Parent page: '''![..]'''
+ * Milestones: '''!milestone:1.0'''
+ * Attachment: '''!attachment:example.tgz''' (for current page attachment), '''!attachment:attachment.1073.diff:ticket:944''' 
+(absolute path)
+ * Files: '''!source:trunk/COPYING'''
+ * A specific file revision: '''!source:/trunk/COPYING@200'''
+ * A particular line of a specific file revision: '''!source:/trunk/COPYING@200#L25'''
+Display:
+ * Tickets: #1 or ticket:1
+ * Ticket comments: comment:1:ticket:2 
+ * Reports: {1} or report:1
+ * Changesets: r1, [1], changeset:1 or (restricted) [1/trunk], changeset:1/trunk
+ * Revision log: r1:3, [1:3] or log:@1:3, log:trunk@1:3, [2:5/trunk]
+ * Diffs (requires [milestone:0.10 0.10]): diff:@1:3, diff:tags/trac-0.9.2/wiki-default//tags/trac-0.9.3/wiki-default or diff:trunk/trac@3538//sandbox/vc-refactoring@3539
+ * Wiki pages: CamelCase or wiki:CamelCase
+ * Parent page: [..]
+ * Milestones: milestone:1.0
+ * Attachment: attachment:example.tgz (for current page attachment), attachment:attachment.1073.diff:ticket:944 
+(absolute path)
+ * Files: source:trunk/COPYING
+ * A specific file revision: source:/trunk/COPYING@200
+ * A particular line of a specific file revision: source:/trunk/COPYING@200#L25
+
+'''Note:''' The wiki:CamelCase form is rarely used, but it can be convenient to refer to
+pages whose names do not follow WikiPageNames rules, i.e., single words,
+non-alphabetic characters, etc. See WikiPageNames for more about features specific
+to links to Wiki page names.
+
+Trac links using the full (non-shorthand) notation can also be given a custom
+link title like this:
+
+{{{
+[ticket:1 This is a link to ticket number one].
+}}}
+
+Display: [ticket:1 This is a link to ticket number one].
+
+If the title is omitted, only the id (the part after the colon) is displayed:
+
+{{{
+[ticket:1]
+}}}
+
+Display: [ticket:1]
+
+`wiki` is the default if the namespace part of a full link is omitted (''since version 0.10''):
+
+{{{
+[SandBox the sandbox]
+}}}
+
+Display: [SandBox the sandbox]
+
+TracLinks are a very simple idea, but actually allow quite a complex network of information. In practice, it's very intuitive and simple to use, and we've found the "link trail" extremely helpful to better understand what's happening in a project or why a particular change was made.
+
+
+== Advanced use of TracLinks ==
+
+=== Relative links ===
+
+To create a link to a specific anchor in a page, use '#':
+{{{
+ [#Relativelinks relative links]
+}}}
+Displays:
+  [#Relativelinks relative links]
+
+Hint: when you move your mouse over the title of a section, a '¶' character will be displayed. This is a link to that specific section and you can use this to copy the `#...` part inside a relative link to an anchor.
+
+To create a link to a [trac:SubWiki SubWiki]-page just use a '/':
+{{{
+ WikiPage/SubWikiPage or ./SubWikiPage
+}}}
+
+To link from a [trac:SubWiki SubWiki] page to a parent, simply use a '..':
+{{{
+  [..]
+}}}
+
+To link from a [trac:SubWiki SubWiki] page to a sibling page, use a '../':
+{{{
+  [../Sibling see next sibling]
+}}}
+
+''(Changed in 0.11)'' Note that in Trac 0.10, using e.g. `[../newticket]`  may have worked for linking to the /newticket top-level URL, but now in 0.11 it will stay in the wiki namespace and link to a sibling page. See [#Server-relativelinks] for the new syntax.
+
+=== InterWiki links ===
+
+Other prefixes can be defined freely and made to point to resources in other Web applications. The definition of those prefixes as well as the URLs of the corresponding Web applications is defined in a special Wiki page, the InterMapTxt page. Note that while this could be used to create links to other Trac environments, there's a more specialized way to register other Trac environments which offers greater flexibility.
+
+=== InterTrac links ===
+
+This can be seen as a kind of InterWiki link specialized for targeting other Trac projects.
+
+Any type of Trac links could be written in one Trac environment and actually refer to resources present in another Trac environment, provided the Trac link is prefixed by the name of that other Trac environment followed by a colon. That other Trac environment must be registered, under its name or an alias. See InterTrac for details. 
+
+A distinctive advantage of InterTrac links over InterWiki links is that the shorthand form of Trac links usually have a way to understand the InterTrac prefixes. For example, links to Trac tickets can be written #T234 (if T was set as an alias for Trac), links to Trac changesets can be written [trac 1508].
+
+=== Server-relative links ===
+
+It is often useful to be able to link to objects in your project that
+have no built-in Trac linking mechanism, such as static resources, `newticket`,
+a shared `/register` page on the server, etc.
+
+To link to resources inside the project, use either an absolute path from the project root, 
+or a relative link from the URL of the current page:
+
+{{{
+[/ticket.php/new Create a new ticket]
+[/ home]
+}}}
+
+Display: [/ticket.php/new newticket] [/ home]
+
+To link to another location on the server (outside the project), use the '//location' link syntax:
+
+{{{
+[//register Register Here]
+}}}
+
+Display: [//register Register Here]
+
+=== Quoting space in TracLinks ===
+
+Immediately after a TracLinks prefix, targets containing space characters should
+be enclosed in a pair of quotes or double quotes.
+
+Examples:
+ * !wiki:"The whitespace convention"
+ * !attachment:'the file.txt' or
+ * !attachment:"the file.txt" 
+ * !attachment:"the file.txt:ticket:123" 
+
+Display:
+ * wiki:"The whitespace convention"
+ * attachment:'the file.txt' or
+ * attachment:"the file.txt" 
+ * attachment:"the file.txt:ticket:123" 
+
+
+=== Escaping Links ===
+
+To prevent parsing of a !TracLink, you can escape it by preceding it with a '!' (exclamation mark).
+{{{
+ !NoLinkHere.
+ ![42] is not a link either.
+}}}
+
+Display:
+ !NoLinkHere.
+ ![42] is not a link either.
+
+
+=== Parameterized Trac links ===
+
+The Trac links target Trac resources which have generally more than one way to be rendered, according to some extra parameters. For example, a Wiki page can accept a `version` or a `format` parameter, a report can make use of dynamic variables, etc.
+
+Any Trac links can support an arbitrary set of parameters, written in the same way as they would be for the corresponding URL. Some examples:
+ - `wiki:Today?format=txt` - wiki:Today?format=txt
+ - `wiki:Today?version=1` - wiki:Today?version=1
+ - `[/newticket?component=module1 create a ticket for module1]`
+
+
+== TracLinks Reference ==
+The following sections describe the individual link types in detail, as well as several notes advanced usage of links.
+
+=== attachment: links ===
+
+The link syntax for attachments is as follows:
+ * !attachment:the_file.txt creates a link to the attachment the_file.txt of the current object
+ * !attachment:the_file.txt:wiki:MyPage creates a link to the attachment the_file.txt of the !MyPage wiki page
+ * !attachment:the_file.txt:ticket:753 creates a link to the attachment the_file.txt of the ticket 753
+
+Note that the older way, putting the filename at the end, is still supported: !attachment:ticket:753:the_file.txt.
+
+If you'd like to create a direct link to the content of the attached file instead of a link to the attachment page, simply use `raw-attachment:` instead of `attachment:`.
+
+This can be useful for pointing directly to an HTML document, for example. Note that for this use case, you'd have to allow the web browser to render the content by setting `[attachment] render_unsafe_content = yes` (see TracIni#attachment-section). Caveat: only do that in environments for which you're 100% confident you can trust the people who are able to attach files, as otherwise this would open up your site to [wikipedia:Cross-site_scripting cross-site scripting] attacks.
+
+See also [#export:links].
+
+=== comment: links ===
+
+When you're inside a given tickets, you can simply write e.g. !comment:3 to link to the third change comment.
+It's also possible to link to a comment of a specific ticket from anywhere using one of the following syntax:
+ - !comment:3:ticket:123 - comment:3:ticket:123 
+ - !ticket:123#comment:3 - ticket:123#comment:3 (note that you can't write !#123#!comment:3!)
+
+=== query: links ===
+
+See TracQuery#UsingTracLinks and [#ticket:links].
+
+=== search: links ===
+
+See TracSearch#SearchLinks 
+
+=== ticket: links ===
+
+Besides the obvious `ticket:id` form, it is also possible to specify a list of tickets or even a range of tickets instead of the `id`. This generates a link to a custom query view containing this fixed set of tickets.
+
+Example: 
+ - `ticket:5000-6000` - ticket:5000-6000
+ - `ticket:1,150` - ticket:1,150
+
+=== timeline: links ===
+
+Links to the timeline can be created by specifying a date in the ISO:8601 format. The date can be optionally followed by a time specification. The time is interpreted as being UTC time, but alternatively you can specify your local time, followed by your timezone if you don't want to compute the UTC time.
+
+Examples:
+ - `timeline:2008-01-29`
+ - `timeline:2008-01-29T15:48`
+ - `timeline:2008-01-29T16:48Z+01`
+
+''(since Trac 0.11)''
+
+=== wiki: links ===
+
+See WikiPageNames and [#QuotingspaceinTracLinks quoting space in TracLinks] above.
+
+=== Version Control related links ===
+==== source: links ====
+
+The default behavior for a source:/some/path link is to open the directory browser 
+if the path points to a directory and otherwise open the log view.
+
+It's also possible to link directly to a specific revision of a file like this:
+ - `source:/some/file@123` - source:/some/file@123 - link to the file's revision 123
+ - `source:/some/file@head` - link explicitly to the latest revision of the file
+
+If the revision is specified, one can even link to a specific line number:
+ - `source:/some/file@123#L10`
+ - `source:/tag/0.10@head#L10`
+
+Finally, one can also highlight an arbitrary set of lines:
+ - `source:/some/file@123:10-20,100,103#L99` - source:/some/file@123:10-20,100,103#L99 - highlight lines 10 to 20, and lines 100 and 103.
+
+==== export: links ====
+
+To force the download of a file in the repository, as opposed to displaying it in the browser, use the `export` link.  Several forms are available:
+ * `export:/some/file` - get the HEAD revision of the specified file
+ * `export:123:/some/file` - get revision 123 of the specified file
+ * `export:/some/file@123` - get revision 123 of the specified file
+
+This can be very useful for displaying XML or HTML documentation with correct stylesheets and images, in case that has been checked in into the repository. Note that for this use case, you'd have to allow the web browser to render the content by setting `[browser] render_unsafe_content = yes` (see TracIni#browser-section), otherwise Trac will force the files to be downloaded as attachments for security concerns. 
+
+If the path is to a directory in the repository instead of a specific file, the source browser will be used to display the directory (identical to the result of `source:/some/dir`).
+
+==== log: links ====
+
+The `log:` links are used to display revision ranges. In its simplest form, it can link to the latest revisions from the specified path, but it can also support displaying an arbitrary set of revisions.
+ - `log:/` - log:/ - the latest revisions starting at the root of the repository
+ - `log:/trunk/tools` - the latest revisions in `trunk/tools`
+ - `log:/trunk/tools@10000` - the revisions in `trunk/tools` starting from  revision 10000
+ - `log:@20788,20791:20795` - list revision 20788 and the 20791 to 20795 revision range
+ - `log:/trunk/tools@20788,20791:20795` - list revision 20788 and the revisions from the 20791 to 20795 range which affect the given path
+
+There are short forms for revision ranges as well:
+ - `[20788,20791:20795]`
+ - `[20788,20791:20795/trunk/tools]`
+ - `r20791:20795` (but not `r20788,20791:20795` nor `r20791:20795/trunk`)
+
+Finally, note that in all of the above, a revision range can be written indifferently `x:y` or `x-y`.
+
+----
+See also: WikiFormatting, TracWiki, WikiPageNames, InterTrac, InterWiki
diff --git a/defaults/help/Plugins b/defaults/help/Plugins
new file mode 100644 (file)
index 0000000..f315c14
--- /dev/null
@@ -0,0 +1,13 @@
+= Plugins =
+
+mtrack has a simple plugin system that allows a plugin class to be loaded a configured using the [help:ConfigIni#plugins configuration file].
+
+mtrack ships with the following plugins:
+
+||Name||Purpose||
+||[help:plugin/AuthHTTP MTrackAuth_HTTP]||Use HTTP authentication||
+||[help:plugin/CommitCheckNoEmpty MTrackCommitCheck_NoEmptyLogMessage]||Prevent commits with no log message||
+||[help:plugin/CommitCheckTimeRef MTrackCommitCheck_RequiresTimeReference]||Prevent commits that don't include time tracking information||
+||[help:plugin/Recaptcha MTrackCaptcha_Recaptcha]||Require recaptcha for submissions||
+||[help:plugin/OpenID MTrackAuth_OpenID]||Use OpenID for public authenticated access control||
+
diff --git a/defaults/help/SSH b/defaults/help/SSH
new file mode 100644 (file)
index 0000000..581d948
--- /dev/null
@@ -0,0 +1,127 @@
+'''AT THIS TIME I SUGGEST THAT YOU ONLY ENABLE THIS FOR SITES USING CONTROLLED HTTP AUTHENTICATION, NOT OPENID'''
+
+ = Notes on setting up SSH with mtrack. =
+
+$MTRACK is the path to your mtrack installation.
+
+Create a user account using your system adduser or useradd tool.
+If you're on OSX, you have to perform the creation manually (see below).
+
+The username you pick will be included in the repo URLs that your
+contributors will use, so pick something appropriate.
+
+Make sure that the primary group of the user matches that of your webserver,
+so that both the mtrack web application and the server side SCM tools can
+both access the repositories.
+
+I've picked ''code'' as the username, and have set the home directory
+to be in my mtrack instance vardir, $MTRACK/var/codehome.
+
+ == OSX ==
+
+On OSX:  manually reate a user.  Make sure that PrimaryGroupID matches your
+webserver.
+
+{{{
+sudo -s
+dscl . -create /Users/code
+dscl . -create /Users/code UserShell $MTRACK/bin/codeshell
+dscl . -create /Users/code RealName "SSH wrapper for mtrack"
+dscl . -create /Users/code UniqueID 600
+dscl . -create /Users/code PrimaryGroupID 20
+dscl . -create /Users/code NFSHomeDirectory $MTRACK/var/codehome
+dscl . -create /Users/code Password '*'
+}}}
+
+ == Other Unix ==
+
+Make sure that you set the password to '*' so that regular password based
+logins are not allowed for this user.  Also make sure that you set the shell to
+$MTRACK/bin/codeshell so that the possible set of commands is restricted to
+just the configured SCM tools (hg, git, svn).
+
+ == Next step ==
+
+Depending on your system, you may need to create the home directory.
+You will also need to create the .ssh directory.
+
+{{{
+mkdir -p $MTRACK/var/codehome/.ssh
+chown code:staff $MTRACK/var/codehome
+}}}
+
+ == Mercurial Trust ==
+
+The commit hooks won't operate for repos created by the web server when pushed
+to over SSH, unless you tell Mercurial to trust the web server user.
+
+You can do this by creating an .hgrc in the home directory of your "code" user.
+Here, "_www" is the username of my web server (OS/X).
+
+These are the contents of {{{$MTRACK/var/codehome/.hgrc}}}:
+
+{{{
+[trusted]
+users = _www
+}}}
+
+ == Config File ==
+
+There are two setting that need to be placed in your config.ini file.  Both are
+required.  The first is the serverurl, which is the user@host which your users
+will use to access your server.  This should be the public name or IP of the
+system.  The second is the location of the authorized_keys2 file for your
+"code" user.  This must be the full path to the file.
+
+{{{
+[repos]
+serverurl = "code@example.com"
+authorized_keys2 = "/Users/wez/Sites/mtrack/var/codehome/.ssh/authorized_keys2"
+}}}
+
+The mtrack repo browser will use the serverurl to display the command that will
+be used to check out the code.  For example, the following commands are used to
+access the "wez/merc", "wez/git" and "wez/svn" repos, which were created in the
+code browser as mercurial, git and subversion repositories, respectively.
+
+{{{
+$ hg clone ssh://code@example.com/wez/merc
+$ git clone code@example.com:wez/git
+$ svn checkout svn+ssh://code@example.com/wez/svn/BRANCHNAME
+}}}
+
+ == SSH Keys ==
+
+Each user can supply their own SSH keys by clicking on their username and
+then the "Edit my details" button.
+
+With SSH key(s) in the system, the next step is to configure the "code" user to see them.
+
+In your crontab, set up a job to run as the "code" user.  This can run as frequently as you like--the longer the interval between runs, the longer it will take for modified SSH keys to take effect.
+
+{{{
+0,15,30,45 * * * * su code -c "php $MTRACK/bin/make-authorized-keys.php" >/dev/null 2>/dev/null
+}}}
+
+This script will pull out the key information from the user data in the mtrack
+database and generate an authorized_keys2 file that routes access via the
+"codeshell" script.
+
+The effect of this is that your users will now be able to access your system
+over SSH and will be able to run hg, git or svn in a mode that only allows them
+to operate on repositories contained in var/repos.
+
+ == On Security ==
+
+How secure is this?  At the time of writing, this configuration has the
+following implications:
+
+ * It creates a new user that accepts public-key authentication only over ssh
+ * Any authenticated mtrack user can add their ssh keys to the allowed set
+ * Any repos created by mtrack are thus accessible (read/write) to any authenticated mtrack user
+
+'''IMPORTANT''': if you have enabled OpenID login, this means that ANY entity
+with an OpenID can add ssh keys and gain read/write access to all of the repos
+created by mtrack, but not gain full shell access.
+
+
diff --git a/defaults/help/Searching b/defaults/help/Searching
new file mode 100644 (file)
index 0000000..f7b22d9
--- /dev/null
@@ -0,0 +1,264 @@
+= Searching mtrack =
+
+mtrack maintains a searchable index of the textual portions of tickets
+and wiki pages, so that you can quickly find that elusive note when
+you need it.
+
+== Search shortcuts ==
+
+If you type one of the following special strings into the search box
+and hit the search button, instead of searching, mtrack will redirect
+you to the appropriate page:
+
+ !#123::
+       will take you to the ticket page for that numbered ticket
+
+
+== Querying the search index ==
+
+A search query may be broken up into a series of search terms and special
+operators.  
+
+=== Terms ===
+
+A query is broken up into terms and operators. There are three types of terms:
+
+ Single Term::
+       is a single word such as "test" or "hello"
+ Phrase::
+       is a group of words surrounded by double quotes such as "hello dolly".
+ Subquery::
+       is a query surrounded by parentheses such as "(hello dolly)".
+
+Multiple terms can be combined together with boolean operators to form complex
+queries.
+
+=== Fields ===
+
+When performing a search you can either specify a field to query against, or
+leave the field unspecified to query against all possible fields.
+
+You can search specific fields by entering the field name followed by a colon, followed by the term you are looking for.
+
+For example, if you want to search wiki content for the word "search" you might enter the following:
+
+{{{
+       wiki:search
+}}}
+
+If you are looking for a ticket with a particular summary and a specific word
+in the description:
+
+{{{
+       summary:"failed open" description:"file not found"
+}}}
+
+Note that the following is not the same as the above, as it will only search
+the summary field for the word "failed", the description field for the word
+"file" and all the rest of the words will be searched against all of the
+possible fields:
+
+{{{
+       summary:failed open description:file not found
+}}}
+
+==== Available fields ====
+
+||Item||Field||Purpose||
+||Ticket||summary||The one-line ticket summary||
+||Ticket||description||The ticket description||
+||Ticket||changelog||The changelog field||
+||Wiki||wiki||The content of the wiki page||
+
+=== Wildcards ===
+
+You may use single and multiple character wildcard searches within single
+terms, but not within phrase queries.
+
+To perform a single character wildcard search, use the "?" symbol.
+
+To perform a multiple character wildcard search, use the "*" symbol.
+
+The single character wildcard search looks for strings that match the term with the "?" replaced by any single character.  For example, to search for "text" or "test" you can use the search:
+
+{{{
+               te?t
+}}}
+
+Multiple character wildcard searches look for 0 or more characters when
+matching strings against terms.  For example, to search test, tests or tester,
+you can use the search:
+
+{{{
+               test*
+}}}
+
+You can use "?", "*" or both at any position of the term, but wildcard matches
+require a non-wildcard prefix of at least 3 characters, otherwise the search
+will not be allowed to continue.
+
+=== Fuzzy Searching ===
+
+You may append the tilde "~" character to a search term to specify
+that a fuzzy search be used, based on the Levenshtein Distance between
+similar words.
+
+To search for a word similar in spelling to "roam":
+
+{{{
+               roam~
+}}}
+
+The above will find terms like "foam" and "roams".
+
+Additional (optional) parameters can specify the required similarity, with
+possible values being fractional numbers between 0 and 1.  As this parameter
+gets closer to 1, it increases the level of similarity required between the two
+words before they will match.
+
+{{{
+               roam~0.8
+}}}
+
+If you do not specify the fuzzy factor, the default value of {{{0.5}}} will be
+used.
+
+
+=== Range Searches ===
+
+Range queries allow the developer or user to match field(s) whose values are
+between an upper and lower bound, either inclusively or exclusively.  Sorting
+is performed lexicographically, and is not limited to numeric values.
+
+mtrack stores dates and times in the form {{{YYYY-MM-DDTHH:MM:SS}}} so that
+they can be meaningfully compared in this fashion.
+
+To perform an inclusive range query:
+
+{{{
+               updated:[2009-08-01 TO 2009-09-01]
+}}}
+
+To perform an exclusive range query:
+
+{{{
+               summary:{bug TO feature}
+}}}
+
+
+=== Proximity Searches ===
+
+To find words from a phrase that are within a certain number of words apart
+from each other in a document, you can append the tilde "~" character to the
+end of the phrase.  For example, to match text where the words "bug" and
+"report" appear within 10 words of each other:
+
+{{{
+       "bug report"~10
+}}}
+
+
+=== Boosting a Term ===
+
+The search results are returned based on the relevance of the match, as
+computed by the search engine for the terms that it found.  To boost the
+relevance of a term you may use the caret "^" symbol followed by a boost factor
+at the end of the term or subquery that you are searching.  The higher the
+boost factor, the more relevant the term will be and the higher ranking it will
+have in the results when it matches:
+
+{{{
+               "crash trace"^4 analysis
+}}}
+
+=== Boolean Operators ===
+
+Boolean operators allow terms to be combined through logic operators.  If you
+include multiple terms in your search, and do not specify a logic operator to
+combine them, then the search engine assumes that you meant to use the "OR"
+operator and will match documents that match any of your criteria.
+
+You may use parentheses to group terms together to construct complex criteria.
+
+The following operators are defined:
+
+==== AND ====
+
+The AND operator means that all terms in the group must match some part of the
+search field(s).
+
+{{{
+       bug AND report
+}}}
+
+{{{
+       "stack trace" and valgrind
+}}}
+
+You may use {{{&&}}} as a synonym for AND.
+
+==== OR ====
+
+The OR operator divides the query into several optional terms.
+
+{{{
+       bug or crash
+}}}
+
+You may use {{{||}}} as a synonym for OR.
+
+==== NOT ====
+
+The NOT operator excludes documents that contain the term after NOT.
+
+{{{
+       bug and not crash
+}}}
+
+You may use "!" as a synonym for NOT.
+
+==== + ==== #required
+
+The "+", or "required", operator stipulates that the term after the "+" symbol
+must match the document.
+
+The following matches text that must contain the word "bug" and may contain the
+word "report":
+
+{{{
+       +bug report
+}}}
+
+==== - ==== #prohibit
+
+The "-", or "prohibit", operator excludes documents that match the term after
+the "-" symbol.
+
+This matches documents that may contain the word "bug" and that do not contain
+the word "report":
+
+{{{
+       bug -report
+}}}
+
+=== Escaping Special Characters ===
+
+The following characters are recognized as special characters by the search
+engine, and must be escaped if you need to use them as part of your search
+terms:
+
+{{{
+       + - && || ! ( ) { } [ ] ^ " ~ * ? : \
+}}}
+
+The "+" and "-" characters are only special when they appear at the start or
+end of a search term and do not need to be escaped when they appear in the
+middle of a term.
+
+The backslash character {{{\}}} can be used to escape these special characters.
+For example, if you intend to search for {{{(1+1):2}}}:
+
+{{{
+       \(1\+1\)\:2
+}}}
+
diff --git a/defaults/help/TicketQuery b/defaults/help/TicketQuery
new file mode 100644 (file)
index 0000000..fd30b8a
--- /dev/null
@@ -0,0 +1,106 @@
+= Trac Ticket Queries =
+[[TracGuideToc]]
+
+In addition to [wiki:TracReports reports], Trac provides support for ''custom ticket queries'', used to display lists of tickets meeting a specified set of criteria. 
+
+To configure and execute a custom query, switch to the ''View Tickets'' module from the navigation bar, and select the ''Custom Query'' link.
+
+== Filters ==
+When you first go to the query page the default filters will display all open tickets, or if you're logged in it will display open tickets assigned to you.  Current filters can be removed by clicking the button to the right with the minus sign on the label.  New filters are added from the pulldown list in the bottom-right corner of the filters box.  Filters with either a text box or a pulldown menu of options can be added multiple times to perform an ''or'' of the criteria.
+
+You can use the fields just below the filters box to group the results based on a field, or display the full description for each ticket.
+
+Once you've edited your filters click the ''Update'' button to refresh your results.
+
+== Navigating Tickets ==
+Clicking on one of the query results will take you to that ticket.  You can navigate through the results by clicking the ''Next Ticket'' or ''Previous Ticket'' links just below the main menu bar, or click the ''Back to Query'' link to return to the query page.  
+
+You can safely edit any of the tickets and continue to navigate through the results using the ''Next/Previous/Back to Query'' links after saving your results.  When you return to the query ''any tickets which were edited'' will be displayed with italicized text.  If one of the tickets was edited such that [[html(<span style="color: grey">it no longer matches the query criteria </span>)]] the text will also be greyed. Lastly, if '''a new ticket matching the query criteria has been created''', it will be shown in bold. 
+
+The query results can be refreshed and cleared of these status indicators by clicking the ''Update'' button again.
+
+== Saving Queries ==
+
+While Trac does not yet allow saving a named query and somehow making it available in a navigable list, you can save references to queries in Wiki content, as described below.
+
+=== Using TracLinks ===
+
+You may want to save some queries so that you can come back to them later.  You can do this by making a link to the query from any Wiki page.
+{{{
+[query:status=new|assigned|reopened&version=1.0 Active tickets against 1.0]
+}}}
+
+Which is displayed as:
+  [query:status=new|assigned|reopened&version=1.0 Active tickets against 1.0]
+
+This uses a very simple query language to specify the criteria (see [wiki:TracQuery#QueryLanguage Query Language]).
+
+Alternatively, you can copy the query string of a query and paste that into the Wiki link, including the leading `?` character:
+{{{
+[query:?status=new&status=assigned&status=reopened&group=owner Assigned tickets by owner]
+}}}
+
+Which is displayed as:
+  [query:?status=new&status=assigned&status=reopened&group=owner Assigned tickets by owner]
+
+=== Using the `[[TicketQuery]]` Macro ===
+
+The [trac:TicketQuery TicketQuery] macro lets you display lists of tickets matching certain criteria anywhere you can use WikiFormatting.
+
+Example:
+{{{
+[[TicketQuery(version=0.6|0.7&resolution=duplicate)]]
+}}}
+
+This is displayed as:
+  [[TicketQuery(version=0.6|0.7&resolution=duplicate)]]
+
+Just like the [wiki:TracQuery#UsingTracLinks query: wiki links], the parameter of this macro expects a query string formatted according to the rules of the simple [wiki:TracQuery#QueryLanguage ticket query language].
+
+A more compact representation without the ticket summaries is also available:
+{{{
+[[TicketQuery(version=0.6|0.7&resolution=duplicate, compact)]]
+}}}
+
+This is displayed as:
+  [[TicketQuery(version=0.6|0.7&resolution=duplicate, compact)]]
+
+Finally if you wish to receive only the number of defects that match the query using the ``count`` parameter.
+
+{{{
+[[TicketQuery(version=0.6|0.7&resolution=duplicate, count)]]
+}}}
+
+This is displayed as:
+  [[TicketQuery(version=0.6|0.7&resolution=duplicate, count)]]
+
+=== Customizing the ''table'' format ===
+You can also customize the columns displayed in the table format (''format=table'') by using ''col=<field>'' - you can specify multiple fields and what order they are displayed by placing pipes (`|`) between the columns like below:
+
+{{{
+[[TicketQuery(max=3,open=0,order=tid,desc=1,format=table,col=resolution|summary|owner)]]
+}}}
+
+This is displayed as:
+[[TicketQuery(max=3,open=0,order=tid,desc=1,format=table,col=resolution|summary|owner)]]
+
+
+=== Query Language ===
+
+`query:` TracLinks and the `[[TicketQuery]]` macro both use a mini “query language” for specifying query filters. Basically, the filters are separated by ampersands (`&`). Each filter then consists of the ticket field name, an operator, and one or more values. More than one value are separated by a pipe (`|`), meaning that the filter matches any of the values.
+
+The available operators are:
+|| '''`=`''' || the field content exactly matches the one of the values ||
+|| '''`~=`''' || the field content contains one or more of the values ||
+|| '''`^=`''' || the field content starts with one of the values ||
+|| '''`$=`''' || the field content ends with one of the values ||
+
+All of these operators can also be negated:
+|| '''`!=`''' || the field content matches none of the values ||
+|| '''`!~=`''' || the field content does not contain any of the values ||
+|| '''`!^=`''' || the field content does not start with any of the values ||
+|| '''`!$=`''' || the field content does not end with any of the values ||
+
+
+----
+See also: TracTickets, TracReports, TracGuide
diff --git a/defaults/help/TracReports b/defaults/help/TracReports
new file mode 100644 (file)
index 0000000..4fbfcea
--- /dev/null
@@ -0,0 +1,249 @@
+= Trac Reports =
+[[TracGuideToc]]
+
+The Trac reports module provides a simple, yet powerful reporting facility
+to present information about tickets in the Trac database.
+
+Rather than have its own report definition format, TracReports relies on standard SQL
+`SELECT` statements for custom report definition. 
+
+  '''Note:''' ''The report module is being phased out in its current form because it seriously limits the ability of the Trac team to make adjustments to the underlying database schema. We believe that the [wiki:TracQuery query module] is a good replacement that provides more flexibility and better usability. While there are certain reports that cannot yet be handled by the query module, we intend to further enhance it so that at some point the reports module can be completely removed. This also means that there will be no major enhancements to the report module anymore.''
+
+  ''You can already completely replace the reports module by the query module simply by disabling the former in [wiki:TracIni trac.ini]:''
+  {{{
+  [components]
+  trac.ticket.report.* = disabled
+  }}}
+  ''This will make the query module the default handler for the “View Tickets” navigation item. We encourage you to try this configuration and report back what kind of features of reports you are missing, if any.''
+
+  '''''You will almost definitely need to restart your httpd at this point.'''''
+
+A report consists of these basic parts:
+ * '''ID''' -- Unique (sequential) identifier 
+ * '''Title'''  -- Descriptive title
+ * '''Description'''  -- A brief description of the report, in WikiFormatting text.
+ * '''Report Body''' -- List of results from report query, formatted according to the methods described below.
+ * '''Footer''' -- Links to alternative download formats for this report.
+
+== Changing Sort Order ==
+Simple reports - ungrouped reports to be specific - can be changed to be sorted by any column simply by clicking the column header. 
+
+If a column header is a hyperlink (red), click the column you would like to sort by. Clicking the same header again reverses the order.
+
+== Changing Report Numbering ==
+There may be instances where you need to change the ID of the report, perhaps to organize the reports better. At present this requires changes to the trac database. The ''report'' table has the following schema (as of 0.10):
+ * id integer PRIMARY KEY
+ * author text
+ * title text
+ * query text
+ * description text
+Changing the ID changes the shown order and number in the ''Available Reports'' list and the report's perma-link. This is done by running something like:
+{{{
+update report set id=5 where id=3;
+}}}
+Keep in mind that the integrity has to be maintained (i.e., ID has to be unique, and you don't want to exceed the max, since that's managed by SQLite someplace).
+
+== Navigating Tickets ==
+Clicking on one of the report results will take you to that ticket. You can navigate through the results by clicking the ''Next Ticket'' or ''Previous Ticket'' links just below the main menu bar, or click the ''Back to Report'' link to return to the report page.
+
+You can safely edit any of the tickets and continue to navigate through the results using the Next/Previous/Back to Report links after saving your results, but when you return to the report, there will be no hint about what has changed, as would happen if you were navigating a list of tickets obtained from a query (see TracQuery#NavigatingTickets). ''(since 0.11)''
+
+== Alternative Download Formats ==
+Aside from the default HTML view, reports can also be exported in a number of alternative formats.
+At the bottom of the report page, you will find a list of available data formats. Click the desired link to 
+download the alternative report format.
+
+=== Comma-delimited - CSV (Comma Separated Values) ===
+Export the report as plain text, each row on its own line, columns separated by a single comma (',').
+'''Note:''' Carriage returns, line feeds, and commas are stripped from column data to preserve the CSV structure.
+
+=== Tab-delimited ===
+Like above, but uses tabs (\t) instead of comma.
+
+=== RSS - XML Content Syndication ===
+All reports support syndication using XML/RSS 2.0. To subscribe to an RSS feed, click the orange 'XML' icon at the bottom of the page. See TracRss for general information on RSS support in Trac.
+
+----
+
+== Creating Custom Reports ==
+
+''Creating a custom report requires a comfortable knowledge of SQL.''
+
+A report is basically a single named SQL query, executed and presented by
+Trac.  Reports can be viewed and created from a custom SQL expression directly
+in from the web interface.
+
+Typically, a report consists of a SELECT-expression from the 'ticket' table,
+using the available columns and sorting the way you want it.
+
+== Ticket columns ==
+The ''ticket'' table has the following columns:
+ * id
+ * type
+ * time
+ * changetime
+ * component
+ * severity  
+ * priority 
+ * owner
+ * reporter
+ * cc
+ * version
+ * milestone
+ * status
+ * resolution
+ * summary
+ * description
+ * keywords
+
+See TracTickets for a detailed description of the column fields.
+
+'''all active tickets, sorted by priority and time'''
+
+'''Example:''' ''All active tickets, sorted by priority and time''
+{{{
+SELECT id AS ticket, status, severity, priority, owner, 
+       time as created, summary FROM ticket 
+  WHERE status IN ('new', 'assigned', 'reopened')
+  ORDER BY priority, time
+}}}
+
+
+----
+
+
+== Advanced Reports: Dynamic Variables ==
+For more flexible reports, Trac supports the use of ''dynamic variables'' in report SQL statements. 
+In short, dynamic variables are ''special'' strings that are replaced by custom data before query execution.
+
+=== Using Variables in a Query ===
+The syntax for dynamic variables is simple, any upper case word beginning with '$' is considered a variable.
+
+Example:
+{{{
+SELECT id AS ticket,summary FROM ticket WHERE priority=$PRIORITY
+}}}
+
+To assign a value to $PRIORITY when viewing the report, you must define it as an argument in the report URL, leaving out the leading '$'.
+
+Example:
+{{{
+ http://trac.edgewall.org/reports/14?PRIORITY=high
+}}}
+
+To use multiple variables, separate them with an '&'.
+
+Example:
+{{{
+ http://trac.edgewall.org/reports/14?PRIORITY=high&SEVERITY=critical
+}}}
+
+
+=== Special/Constant Variables ===
+There is one ''magic'' dynamic variable to allow practical reports, its value automatically set without having to change the URL. 
+
+ * $USER -- Username of logged in user.
+
+Example (''List all tickets assigned to me''):
+{{{
+SELECT id AS ticket,summary FROM ticket WHERE owner=$USER
+}}}
+
+
+----
+
+
+== Advanced Reports: Custom Formatting ==
+Trac is also capable of more advanced reports, including custom layouts,
+result grouping and user-defined CSS styles. To create such reports, we'll use
+specialized SQL statements to control the output of the Trac report engine.
+
+== Special Columns ==
+To format reports, TracReports looks for 'magic' column names in the query
+result. These 'magic' names are processed and affect the layout and style of the 
+final report.
+
+=== Automatically formatted columns ===
+ * '''ticket''' -- Ticket ID number. Becomes a hyperlink to that ticket. 
+ * '''created, modified, date, time''' -- Format cell as a date and/or time.
+
+ * '''description''' -- Ticket description field, parsed through the wiki engine.
+
+'''Example:'''
+{{{
+SELECT id as ticket, created, status, summary FROM ticket 
+}}}
+
+=== Custom formatting columns ===
+Columns whose names begin and end with 2 underscores (Example: '''`__color__`''') are
+assumed to be ''formatting hints'', affecting the appearance of the row.
+ * '''`__group__`''' -- Group results based on values in this column. Each group will have its own header and table.
+ * '''`__color__`''' -- Should be a numeric value ranging from 1 to 5 to select a pre-defined row color. Typically used to color rows by issue priority.
+{{{
+#!html
+<div style="margin-left:7.5em">Defaults: 
+<span style="border: none; color: #333; background: transparent;  font-size: 85%; background: #fdc; border-color: #e88; color: #a22">Color 1</span>
+<span style="border: none; color: #333; background: transparent;  font-size: 85%; background: #ffb; border-color: #eea; color: #880">Color 2</span>
+<span style="border: none; color: #333; background: transparent;  font-size: 85%; background: #fbfbfb; border-color: #ddd; color: #444">Color 3</span>
+<span style="border: none; color: #333; background: transparent; font-size: 85%; background: #e7ffff; border-color: #cee; color: #099">Color 4</span>
+<span style="border: none; color: #333; background: transparent;  font-size: 85%; background: #e7eeff; border-color: #cde; color: #469">Color 5</span>
+</div>
+}}}
+ * '''`__style__`''' -- A custom CSS style expression to use for the current row. 
+
+'''Example:''' ''List active tickets, grouped by milestone, colored by priority''
+{{{
+SELECT p.value AS __color__,
+     t.milestone AS __group__,
+     (CASE owner WHEN 'daniel' THEN 'font-weight: bold; background: red;' ELSE '' END) AS __style__,
+       t.id AS ticket, summary
+  FROM ticket t,enum p
+  WHERE t.status IN ('new', 'assigned', 'reopened') 
+    AND p.name=t.priority AND p.type='priority'
+  ORDER BY t.milestone, p.value, t.severity, t.time
+}}}
+
+'''Note:''' A table join is used to match ''ticket'' priorities with their
+numeric representation from the ''enum'' table.
+
+=== Changing layout of report rows ===
+By default, all columns on each row are display on a single row in the HTML
+report, possibly formatted according to the descriptions above. However, it's
+also possible to create multi-line report entries.
+
+ * '''`column_`''' -- ''Break row after this''. By appending an underscore ('_') to the column name, the remaining columns will be be continued on a second line.
+
+ * '''`_column_`''' -- ''Full row''. By adding an underscore ('_') both at the beginning and the end of a column name, the data will be shown on a separate row.
+
+ * '''`_column`'''  --  ''Hide data''. Prepending an underscore ('_') to a column name instructs Trac to hide the contents from the HTML output. This is useful for information to be visible only if downloaded in other formats (like CSV or RSS/XML).
+
+'''Example:''' ''List active tickets, grouped by milestone, colored by priority, with  description and multi-line layout''
+
+{{{
+SELECT p.value AS __color__,
+       t.milestone AS __group__,
+       (CASE owner 
+          WHEN 'daniel' THEN 'font-weight: bold; background: red;' 
+          ELSE '' END) AS __style__,
+       t.id AS ticket, summary AS summary_,             -- ## Break line here
+       component,version, severity, milestone, status, owner,
+       time AS created, changetime AS modified,         -- ## Dates are formatted
+       description AS _description_,                    -- ## Uses a full row
+       changetime AS _changetime, reporter AS _reporter -- ## Hidden from HTML output
+  FROM ticket t,enum p
+  WHERE t.status IN ('new', 'assigned', 'reopened') 
+    AND p.name=t.priority AND p.type='priority'
+  ORDER BY t.milestone, p.value, t.severity, t.time
+}}}
+
+=== Reporting on custom fields ===
+
+If you have added custom fields to your tickets (a feature since v0.8, see TracTicketsCustomFields), you can write a SQL query to cover them. You'll need to make a join on the ticket_custom table, but this isn't especially easy.
+
+If you have tickets in the database ''before'' you declare the extra fields in trac.ini, there will be no associated data in the ticket_custom table. To get around this, use SQL's "LEFT OUTER JOIN" clauses. See [trac:TracIniReportCustomFieldSample TracIniReportCustomFieldSample] for some examples.
+
+'''Note that you need to set up permissions in order to see the buttons for adding or editing reports.'''
+
+----
+See also: TracTickets, TracQuery, TracGuide, [http://www.sqlite.org/lang_expr.html Query Language Understood by SQLite]
\ No newline at end of file
diff --git a/defaults/help/WikiFormatting b/defaults/help/WikiFormatting
new file mode 100644 (file)
index 0000000..92e7c71
--- /dev/null
@@ -0,0 +1,403 @@
+= !WikiFormatting =
+
+Wiki markup is a core feature, tightly integrating all the other parts of mtrack into a flexible and powerful whole.
+
+mtrack has a built in small and powerful wiki rendering engine. This wiki engine implements an ever growing subset of the commands from other popular Wikis,
+especially [http://moinmoin.wikiwikiweb.de/ MoinMoin]. 
+
+
+This page demonstrates the formatting syntax available anywhere !WikiFormatting is allowed.
+
+
+== Font Styles ==
+
+The Trac wiki supports the following font styles:
+{{{
+ * '''bold''', '''!''' can be bold too''', and '''! '''
+ * ''italic''
+ * '''''bold italic'''''
+ * __underline__
+ * {{{monospace}}} or `monospace`
+ * ~~strike-through~~
+ * ^superscript^ 
+ * ,,subscript,,
+}}}
+
+Display:
+ * '''bold''', '''!''' can be bold too''', and '''! '''
+ * ''italic''
+ * '''''bold italic'''''
+ * __underline__
+ * {{{monospace}}} or `monospace`
+ * ~~strike-through~~
+ * ^superscript^ 
+ * ,,subscript,,
+
+Notes:
+ * `{{{...}}}` and {{{`...`}}} commands not only select a monospace font, but also treat their content as verbatim text, meaning that no further wiki processing is done on this text.
+ * {{{ ! }}} tells wiki parser to not take the following characters as wiki format, so pay attention to put a space after !, e.g. when ending bold.
+
+== Headings ==
+
+You can create heading by starting a line with one up to five ''equal'' characters ("=")
+followed by a single space and the headline text. The line should end with a space 
+followed by the same number of ''='' characters.
+The heading might optionally be followed by an explicit id. If not, an implicit but nevertheless readable id will be generated.
+
+Example:
+{{{
+= Heading =
+== Subheading ==
+=== About ''this'' ===
+=== Explicit id === #using-explicit-id-in-heading
+}}}
+
+Display:
+= Heading =
+== Subheading ==
+=== About ''this'' ===
+=== Explicit id === #using-explicit-id-in-heading
+
+== Paragraphs ==
+
+A new text paragraph is created whenever two blocks of text are separated by one or more empty lines.
+
+A forced line break can also be inserted, using:
+{{{
+Line 1[[BR]]Line 2
+}}}
+Display:
+
+Line 1[[BR]]Line 2
+
+
+== Lists ==
+
+The wiki supports both ordered/numbered and unordered lists.
+
+Example:
+{{{
+ * Item 1
+   * Item 1.1
+      * Item 1.1.1   
+      * Item 1.1.2
+      * Item 1.1.3
+   * Item 1.2
+ * Item 2
+
+ 1. Item 1
+   a. Item 1.a
+   a. Item 1.b
+      i. Item 1.b.i
+      i. Item 1.b.ii
+ 1. Item 2
+And numbered lists can also be given an explicit number:
+ 3. Item 3
+}}}
+
+Display:
+ * Item 1
+   * Item 1.1
+      * Item 1.1.1
+      * Item 1.1.2
+      * Item 1.1.3
+   * Item 1.2
+ * Item 2
+
+ 1. Item 1
+   a. Item 1.a
+   a. Item 1.b
+      i. Item 1.b.i
+      i. Item 1.b.ii
+ 1. Item 2
+And numbered lists can also be given an explicit number:
+ 3. Item 3
+
+Note that there must be one or more spaces preceding the list item markers, otherwise the list will be treated as a normal paragraph.
+
+
+== Definition Lists ==
+
+
+The wiki also supports definition lists.
+
+Example:
+{{{
+ llama::
+   some kind of mammal, with hair
+ ppython::
+   some kind of reptile, without hair
+   (can you spot the typo?)
+}}}
+
+Display:
+ llama::
+   some kind of mammal, with hair
+ ppython::
+   some kind of reptile, without hair
+   (can you spot the typo?)
+
+Note that you need a space in front of the defined term.
+
+
+== Preformatted Text ==
+
+Block containing preformatted text are suitable for source code snippets, notes and examples. Use three ''curly braces'' wrapped around the text to define a block quote. The curly braces need to be on a separate line.
+  
+Example:
+{{{
+ {{{
+  def HelloWorld():
+      print "Hello World"
+ }}}
+}}}
+
+Display:
+{{{
+ def HelloWorld():
+     print "Hello World"
+}}}
+
+
+== Blockquotes ==
+
+In order to mark a paragraph as blockquote, indent that paragraph with two spaces.
+
+Example:
+{{{
+  This text is a quote from someone else.
+}}}
+
+Display:
+  This text is a quote from someone else.
+
+== Discussion Citations ==
+
+To delineate a citation in an ongoing discussion thread, such as the ticket comment area, e-mail-like citation marks (">", ">>", etc.) may be used.  
+
+Example:
+{{{
+>> Someone's original text
+> Someone else's reply text
+My reply text
+}}}
+
+Display:
+>> Someone's original text
+> Someone else's reply text
+My reply text
+
+''Note: Some !WikiFormatting elements, such as lists and preformatted text, are  lost in the citation area.  Some reformatting may be necessary to create a clear citation.''
+
+== Tables ==
+
+Simple tables can be created like this:
+{{{
+||Cell 1||Cell 2||Cell 3||
+||Cell 4||Cell 5||Cell 6||
+}}}
+
+Display:
+||Cell 1||Cell 2||Cell 3||
+||Cell 4||Cell 5||Cell 6||
+
+== Links ==
+
+Hyperlinks are automatically created for WikiPageNames and URLs. !WikiPageLinks can be disabled by prepending an exclamation mark "!" character, such as {{{!WikiPageLink}}}.
+
+Example:
+{{{
+ TitleIndex, http://www.edgewall.com/, !NotAlink
+}}}
+
+Display:
+ TitleIndex, http://www.edgewall.com/, !NotAlink
+
+Links can be given a more descriptive title by writing the link followed by a space and a title and all this inside square brackets.  If the descriptive title is omitted, then the explicit prefix is discarded, unless the link is an external link. This can be useful for wiki pages not adhering to the WikiPageNames convention.
+
+Example:
+{{{
+ * [http://www.edgewall.com/ Edgewall Software]
+ * [wiki:TitleIndex Title Index]
+ * [wiki:ISO9000]
+}}}
+
+Display:
+ * [http://www.edgewall.com/ Edgewall Software]
+ * [wiki:TitleIndex Title Index]
+ * [wiki:ISO9000]
+
+== Trac Links ==
+
+Wiki pages can link directly to other parts of the Trac system. Pages can refer to tickets, reports, changesets, milestones, source files and other Wiki pages using the following notations:
+{{{
+ * Tickets: #1 or ticket:1
+ * Reports: {1} or report:1
+ * Changesets: r1, [1] or changeset:1
+ * ...
+}}}
+
+Display:
+ * Tickets: #1 or ticket:1
+ * Reports: {1} or report:1
+ * Changesets: r1, [1] or changeset:1
+ * ... 
+
+There are many more flavors of Trac links, see TracLinks for more in-depth information.
+
+
+== Escaping Links and WikiPageNames ==
+
+You may avoid making hyperlinks out of TracLinks by preceding an expression with a single "!" (exclamation mark).
+
+Example:
+{{{
+ !NoHyperLink
+ !#42 is not a link
+}}}
+
+Display:
+ !NoHyperLink
+ !#42 is not a link
+
+
+{{{
+#!comment
+== Images ==
+
+Urls ending with `.png`, `.gif` or `.jpg` are no longer automatically interpreted as image links, and converted to `<img>` tags.
+
+You now have to use the ![[Image]] macro. The simplest way to include an image is to upload it as attachment to the current page, and put the filename in a macro call like `[[Image(picture.gif)]]`.
+
+In addition to the current page, it is possible to refer to other resources:
+ * `[[Image(wiki:WikiFormatting:picture.gif)]]` (referring to attachment on another page)
+ * `[[Image(ticket:1:picture.gif)]]` (file attached to a ticket)
+ * `[[Image(htdocs:picture.gif)]]` (referring to a file inside project htdocs)
+ * `[[Image(source:/trunk/trac/htdocs/trac_logo_mini.png)]]` (a file in repository)
+
+Example display: [[Image(htdocs:../common/trac_logo_mini.png)]]
+
+
+See WikiMacros for further documentation on the `[[Image()]]` macro.
+
+}}}
+
+== Macros ==
+
+Macros are ''custom functions'' to insert dynamic content in a page.
+
+Example:
+{{{
+ [[RecentChanges(Trac,3)]]
+}}}
+
+Display:
+ [[RecentChanges(Trac,3)]]
+
+See WikiMacros for more information, and a list of installed macros.
+
+
+== Processors ==
+
+Trac supports alternative markup formats using WikiProcessors. For example, processors are used to write pages in HTML.
+
+Example 1:
+{{{
+#!html
+<pre class="wiki">{{{
+#!html
+&lt;h1 style="text-align: right; color: blue"&gt;HTML Test&lt;/h1&gt;
+}}}</pre>
+}}}
+
+Display:
+{{{
+#!html
+<h1 style="text-align: right; color: blue">HTML Test</h1>
+}}}
+
+Example:
+{{{
+#!html
+<pre class="wiki">{{{
+#!python
+class Test:
+
+    def __init__(self):
+        print "Hello World"
+if __name__ == '__main__':
+   Test()
+}}}</pre>
+}}}
+
+Display:
+{{{
+#!python
+class Test:
+    def __init__(self):
+        print "Hello World"
+if __name__ == '__main__':
+   Test()
+}}}
+
+Perl:
+{{{
+#!perl
+my ($test) = 0;
+if ($test > 0) {
+    print "hello";
+}
+}}}
+
+See WikiProcessors for more information.
+
+
+== Comments ==
+
+Comments can be added to the plain text. These will not be rendered and will not display in any other format than plain text.
+{{{
+{{{
+#!comment
+Your comment here
+}}}
+}}}
+
+== Data output from SQL command line utilities ==
+
+If you have text that you want to copy and paste from a command line utility,
+such as psql, then you can enclose it in the ''dataset'' processor:
+
+{{{
+{{{
+#!dataset
+            current_query             | procpid | usename | client_addr  |     elapsed
+--------------------------------------+---------+---------+--------------+-----------------
+ SELECT * FROM build_mailing(59508)   |    6595 | user  | 10.16.40.80 | 00:04:24.377262
+ FETCH NEXT FROM "<unnamed portal 5>" |   27597 | user  | 10.16.40.80 | 00:00:44.208982
+ commit                               |   19188 | user  | 10.16.40.67 | 00:00:00.013402
+ COMMIT                               |   26390 | user  | 10.16.1.56  | 00:00:00.007778
+}}}
+}}}
+
+{{{
+#!dataset
+            current_query             | procpid | usename | client_addr  |     elapsed
+--------------------------------------+---------+---------+--------------+-----------------
+ SELECT * FROM build_mailing(59508)   |    6595 | user  | 10.16.40.80 | 00:04:24.377262
+ FETCH NEXT FROM "<unnamed portal 5>" |   27597 | user  | 10.16.40.80 | 00:00:44.208982
+ commit                               |   19188 | user  | 10.16.40.67 | 00:00:00.013402
+ COMMIT                               |   26390 | user  | 10.16.1.56  | 00:00:00.007778
+}}}
+
+== Miscellaneous ==
+
+Four or more dashes will be replaced by a horizontal line (<HR>)
+
+Example:
+{{{
+ ----
+}}}
+
+Display:
+----
+
diff --git a/defaults/help/bin/Init b/defaults/help/bin/Init
new file mode 100644 (file)
index 0000000..33405c4
--- /dev/null
@@ -0,0 +1,136 @@
+= bin/init.php =
+
+This script is used to initialize a new mtrack instance.  If you want to modify
+an existing mtrack instance, you should try to use the administration
+interface, or use [help:bin/Modify bin/modify.php] instead as ''init.php'' will
+exit when you attempt to use it on an already initialized mtrack instance.
+
+== Synopsis ==
+
+{{{
+% cd $MTRACK
+% php bin/init.php ...parameters...
+}}}
+
+== Parameters ==
+
+=== --disable-index === #disable-index
+
+Disables full-text index generation during setup (affects Trac import).
+
+=== --repo !{name} !{type} !{repopath} === #repo
+
+Defines a source repository named !{name} of type !{type} that can be found on
+the local filesystem at !{repopath}.
+
+Supported repository types are ''hg'' for Mercurial and ''svn'' for Subversion.
+
+You will typically also want to use {{{--link}}} to associate the repository to
+a project.
+
+=== --link !{project} !{repo} !{location} === #link
+
+Defines a link between the project identified by short name !{project} and the
+repository named !{name} by the source location identified by the regex
+!{location}.
+
+To associate the entire repository with a project you would use a simple
+{{{/}}} as the !{location} parameter:
+
+{{{
+% php bin/init.php --repo myrepo svn /path/to/repo \
+       --link myproject myrepo /
+}}}
+
+To have changes made under "trunk/docs" be associated with the doc project, and all others be associated with myproject:
+
+{{{
+% php bin/init.php --repo myrepo svn /path/to/repo \
+       --link myproject myrepo / \
+       --link doc myrepo /trunk/docs/
+}}}
+
+=== --trac !{project} !{tracenv} === #trac
+
+Imports data from a the trac environment on the local filesystem at !{tracenv}, and associate it with the project named !{project}.
+
+!{tracenv} is the same environment path that you would use when running the
+trac admin command line utility.
+
+mtrack can only be used to import SQLite based Trac instances at this time.
+
+You may import multiple trac environments; the first one will be imported in
+as-is, but subsequent trac environments will be imported with some changes to
+avoid the possibility of collisions between ticket numbers and wiki pages.
+
+Subsequent trac imports will prefix ticket numbers with the project name, so
+instead of {{{#123}}}, if you import it to a project named {{{mc}}}, the ticket
+will be {{{#mc123}}}.  Similarly, the wiki pages will be adjusted to live under
+a directory named after the project, so you would end up with
+{{{mc/WikiStart}}} for the main wiki page from that trac instance.
+
+The import will skip Trac wiki pages that contain Trac specific docs (even if
+you modified them in your trac instance); mtrack prefers to keep its own
+documentation out of your wiki history so that it doesn't clutter up what is
+important to you, and also updates automatically when you update mtrack.
+
+Another note about the wiki import is that mtrack stores wiki pages in a
+repository.  If you used hierarchical wiki page names (in other words, have the
+{{{/}}} character in the page names) these are mapped to directories in the
+repository.  If you created a page named {{{Foo}}} and a page named
+{{{Foo/Bar}}} you have a collision between the {{{Foo}}} page and the directory
+named {{{Foo}}} that contains the page named {{{Bar}}}.  The import resolves
+this collision by renaming the {{{Foo}}} page to {{{FooPage}}}.
+
+=== --vardir !{dir} === #vardir
+
+Where to store the database, attachments and search engine state.
+
+If not specified, defaults to a directory named {{{var}}} in the mtrack
+directory.
+
+This location, whether specified explicitly or not, will be created if it does
+not already exist.
+
+=== --config-file !{filename} === #config-file
+
+Where to create the configuration file.
+
+If not specified, defaults to {{{config.ini}}} in the mtrack directory.
+
+=== --author-alias !{filename} === #author-alias
+
+where to find an authors file that maps usernames.  This is used to initialize
+the canonicalizations used by the system.  The format is a file of the form:
+
+{{{
+sourcename=canonicalname
+}}}
+
+The import will replace all instances of sourcename with canonicalname in the
+history, and will record the mapping so that future items will respect it.
+
+You do not need to use this option if you have only a single repository, or if
+you have never changed usernames for any of your contributors.
+
+=== --author-info !{filename} === #author-info
+
+Where to find a file that will be used to initialize the userinfo table. The
+format is:
+
+{{{
+canonid,fullname,email,active,timezone
+}}}
+
+where canonid is the canonical username.
+
+for example:
+
+{{{
+wez,Wez Furlong,wez.spam@netevil.org,1,EST
+}}}
+
+The ''active'' flag indicates whether the account is eligible to be assigned as
+a responsible user when changing tickets.
+
+
diff --git a/defaults/help/bin/Modify b/defaults/help/bin/Modify
new file mode 100644 (file)
index 0000000..98349d0
--- /dev/null
@@ -0,0 +1,45 @@
+= bin/modify.php =
+
+This script can be used to modify an existing mtrack instance.  You should try
+to use the administration interface where possible.
+
+== Synopsis ==
+
+{{{
+% cd $MTRACK
+% php bin/modify.php ...parameters...
+}}}
+
+== Parameters ==
+
+=== --repo !{name} !{type} !{repopath} === #repo
+
+Adds a source repository.  This works in the same way as the
+[help:bin/Init#repo repo option for bin/init.php].
+
+=== --link !{project} !{repo} !{location} === #link
+
+Defines a link between the project identified by short name !{project} and the
+repository named !{name} by the source location identified by the regex
+!{location}.
+
+This works in the same way as the [help:bin/Init#link link option for bin/init.php].
+
+=== --trac !{project} !{tracenv} === #trac
+
+Imports data from a the trac environment on the local filesystem at !{tracenv}, and associate it with the project named !{project}.
+
+This works in the same way as the
+[help:bin/Init#trac trac option for bin/init.php],
+''except that the trac imports will always be treated as
+secondary instances''.  This means that the tickets and wiki pages will all be
+prefixed with the project name.
+
+=== --config-file !{filename} === #config-file
+
+Where to find the pre-existing configuration file.
+
+If not specified, defaults to {{{config.ini}}} in the mtrack directory.
+
+
+
diff --git a/defaults/help/plugin/AuthHTTP b/defaults/help/plugin/AuthHTTP
new file mode 100644 (file)
index 0000000..fbf627f
--- /dev/null
@@ -0,0 +1,50 @@
+= MTrackAuth_HTTP =
+
+By default, mtrack considers every user accessing the application via the web
+server as an anonymous user.  Enabling this plugin will cause mtrack to either
+recognize the HTTP authentication employed by your web server, or in the case
+where the web server does not have authentication enabled, causes mtrack to
+initiate HTTP authentication for itself.
+
+== configuration ==
+
+The plugin is loaded by adding a line like the following to your [help:ConfigIni config.ini]:
+
+{{{
+[plugins]
+MTrackAuth_HTTP = /var/tmp/repos/svn.htgroup, /var/tmp/repos/svn.htpasswd
+}}}
+
+The first parameter is the path to an Apache style group file and the second is
+the path to an Apache style password file.
+
+== Basic vs Digest authentication ==
+
+If your web server is not configured to perform authentication, mtrack will
+initiate it for itself.  You have the option is implementing Basic or Digest
+authentication.  Basic is more widely supported but should be used in
+conjunction with SSL or other network level security so that the password
+cannot be snooped.  Digest authentication does not transmit the password in
+clear text so there is no risk of the password being snooped in the same way as
+Basic auth.
+
+If you choose to use Basic authentication, it should be noted that mtrack
+supports only crypt based password encoding at this time.
+
+=== Enabling Digest authentication ===
+
+By default, mtrack uses Basic authentication.  To use Digest authentication you
+need to create a digest password file instead of the regular password file and
+then tell mtrack to use digest: by prefixing the password file path with
+{{{digest:}}}
+
+{{{
+[plugins]
+MTrackAuth_HTTP = /var/tmp/repos/svn.htgroup, digest:/var/tmp/repos/svn.htpasswd
+}}}
+
+= Groups =
+
+On successful authentication, the groups file is read and the groups of the
+authenticated user are used to determine what rights the user has.
+
diff --git a/defaults/help/plugin/CommitCheckNoEmpty b/defaults/help/plugin/CommitCheckNoEmpty
new file mode 100644 (file)
index 0000000..014bb4f
--- /dev/null
@@ -0,0 +1,15 @@
+= MTrackCommitCheck_NoEmptyLogMessage =
+
+When this plugin is enabled, it prevents commits from taking place if the
+commit has an empty log message.
+
+This restriction will only apply to repositories that have been configured to
+use the mtrack pre-commit hook.
+
+== configuration ==
+
+{{{
+[plugins]
+MTrackCommitCheck_NoEmptyLogMessage =
+}}}
+
diff --git a/defaults/help/plugin/CommitCheckTimeRef b/defaults/help/plugin/CommitCheckTimeRef
new file mode 100644 (file)
index 0000000..b7edad1
--- /dev/null
@@ -0,0 +1,26 @@
+= MTrackCommitCheck_RequiresTimeReference =
+
+When this plugin is enabled, it prevents commits from taking place if the
+commit does not reference a ticket and include time tracking information.
+
+An example of the time reference is shown below, which adds 3.5 hours of effort
+to ticket #123:
+
+{{{
+Compensate for the foo issue; it was hard to track down.
+refs #123 (spent 3.5)
+}}}
+
+If the commit does not reference a ticket, it will be denied.
+
+The log message may reference multiple tickets; this plugin does not require
+that every referenced ticket have an effort associated with it, so long as at
+least one ticket has effort tracked, the commit will be allowed.
+
+== configuration ==
+
+{{{
+[plugins]
+MTrackCommitCheck_RequiresTimeReference =
+}}}
+
diff --git a/defaults/help/plugin/OpenID b/defaults/help/plugin/OpenID
new file mode 100644 (file)
index 0000000..9825013
--- /dev/null
@@ -0,0 +1,56 @@
+= MTrackAuth_OpenID =
+
+When used in a public facing environment, where you desire to have external
+users contributing to your project in terms of bug reports or wiki content, you
+may want to enable OpenID as an authentication mechanism.
+
+This allows users to access your site without having to request credentials and remember passwords and such for your site.
+
+The OpenID implementation in mtrack classes users as anonymous until they
+either explicitly log in or arrive at a page that throws a privilege exception.
+If a privilege exception is raised while the user is anonymous, they will be
+redirected to the OpenID login page to authenticate.
+
+mtrack will not automatically create a local user record for OpenID users as
+they log in.  This is for security purposes; even though the authentication is
+outsourced, the mechanism does not prevent the user from sending erroneous
+information as part of the sign in, and this could potentially be used to
+hijack a pre-existing local user record.
+
+The impact of this is that an OpenID user can comment and contribute to the
+wiki (assuming that permissions are set accordingly; by default the OpenID user
+will be classified as an authenticated user class and thus have rights to the
+wiki and tickets), and their contributions will be attributed to their OpenID
+identity URL.
+
+To establish a local user identity for the OpenID user, an admin user can edit the OpenID user by clicking on their name in the UI.  When the edits are saved, the user will become a local user.
+
+If the OpenID user is also a contributor to the code via the SCM, the admin
+user can add an alias for that user.  For example, the user "wez" is a code
+contributor and also has the OpenID identity URL "http://netevil.org/".  The
+recommended approach for configuring mtrack is to edit the "wez" user details
+and add an alias for "http://netevil.org/".  Now, when "wez" logs in via OpenID
+he will be recognized as "wez" throughout the system, rather then
+"http://netevil.org/" because "wez" is the canonical identifier for that user.
+
+Because OpenID is not a guarantee that the user is trustworthy, you may also want to consider [help:plugin/Recaptcha enabling captcha support].
+
+== configuration ==
+
+The plugin is loaded by adding a line like the following to your [help:ConfigIni config.ini]:
+
+{{{
+[plugins]
+MTrackAuth_OpenID =
+}}}
+
+You may also assign user_classes to OpenID URLs; for instance, the following
+configuration gives Wez Furlong admin rights to your mtrack instance:
+
+{{{
+[user_classes]
+http://netevil.org/ = admin
+}}}
+
+Note that the trailing slash character in the URL is significant.
+
diff --git a/defaults/help/plugin/Recaptcha b/defaults/help/plugin/Recaptcha
new file mode 100644 (file)
index 0000000..ad9e6d8
--- /dev/null
@@ -0,0 +1,23 @@
+= MTrackCaptcha_Recaptcha =
+
+When used in a public facing environment, in order to reduce automated spam,
+you may want to enable a CAPTCHA.  Mtrack has an API that allows different
+captcha implementations to be used, and ships with support for the reCaptcha
+service.
+
+== configuration ==
+
+The plugin is loaded by adding a line like the following to your [help:ConfigIni config.ini]:
+
+{{{
+[plugins]
+MTrackCaptcha_Recaptcha = publickey, privatekey, userclass
+}}}
+
+The first parameter is your publickey key and the second is your privatekey.
+You can obtain keys from [http://recaptcha.net/api/getkey?app=mtrack recaptcha.net].
+
+The userclass parameter indicates which classes (separated by a pipe character)
+of user should have the captcha applied.  The default value is
+{{{anonymous|authenticated}}} which means that everyone except for admin users
+will be presented with a captcha.
diff --git a/defaults/reports/ActiveTickets b/defaults/reports/ActiveTickets
new file mode 100644 (file)
index 0000000..2b18192
--- /dev/null
@@ -0,0 +1,34 @@
+SELECT
+   pri.value AS __color__,
+   (case when t.nsident is null then t.tid else t.nsident end) as ticket,
+   summary,
+   (select mtrack_group_concat(name) from
+      ticket_components tcm
+      left join components c on (tcm.compid = c.compid)
+      where tcm.tid = t.tid) as component,
+   (select mtrack_group_concat(name) from
+      ticket_milestones tm
+      left join milestones m on (tm.mid = m.mid)
+      where
+       tm.tid = t.tid
+      ) as milestone, 
+   classification as type,
+   severity,
+   creat.changedate as created
+FROM
+   tickets t
+   left join priorities pri on (t.priority = pri.priorityname)
+   left join severities sev on (t.severity = sev.sevname)
+   left join changes creat on (t.created = creat.cid) 
+WHERE
+ t.status in ('new', 'assigned', 'reopened')
+ORDER BY
+ pri.value, sev.ordinal,
+ t.created
+
+
+= Active Tickets =
+
+ * List all active tickets by priority.
+ * Color each row based on priority.
+
diff --git a/defaults/reports/Mine b/defaults/reports/Mine
new file mode 100644 (file)
index 0000000..7bd9213
--- /dev/null
@@ -0,0 +1,49 @@
+SELECT
+   pri.value as __color__,
+   (case when t.nsident is null then t.tid else t.nsident end) as ticket,
+   summary,
+   classification as type,
+   (select mtrack_group_concat(name) from
+      ticket_components tcm
+      left join components c on (tcm.compid = c.compid)
+      where tcm.tid = t.tid) as component,
+   (select min(duedate) from
+      ticket_milestones tm
+      left join milestones m on (tm.mid = m.mid)
+      where
+       tm.tid = t.tid
+       and m.duedate is not null
+      ) as due,
+   (select mtrack_group_concat(name) from
+      ticket_milestones tm
+      left join milestones m on (tm.mid = m.mid)
+      where
+       tm.tid = t.tid
+      ) as milestone,
+   severity,
+   priority,
+   estimated - spent as remaining
+FROM
+   tickets t
+   left join priorities pri on (t.priority = pri.priorityname)
+   left join severities sev on (t.severity = sev.sevname)
+WHERE
+ t.status <> 'closed'
+ AND owner = $USER
+ORDER BY
+ case when (select count(duedate) from
+       ticket_milestones tm
+       left join milestones m on (tm.mid = m.mid)
+       where tm.tid = t.tid and m.duedate is not null) > 0 then 1 else 0 end,
+ pri.value, sev.ordinal,
+ due,
+ t.created
+
+
+= My tickets =
+
+This report shows tickets assigned to the logged-in user with the highest
+priority items listed first.
+
+This report is run as part of the [wiki:Today] page, and any changes made here
+will affect that page.
diff --git a/defaults/wiki/Today b/defaults/wiki/Today
new file mode 100644 (file)
index 0000000..6081b80
--- /dev/null
@@ -0,0 +1,10 @@
+= Today =
+
+Welcome to the "Today" page. It shows you the most pertinent tasks for the day.
+
+[[RunReport(Mine)]]
+
+'''Want to change this page?'''
+
+You can edit the content at [wiki:Today], and the ticket list at {Mine}.
+
diff --git a/defaults/wiki/WikiStart b/defaults/wiki/WikiStart
new file mode 100644 (file)
index 0000000..4753d4a
--- /dev/null
@@ -0,0 +1,6 @@
+{{{
+#!comment
+You may safely delete everything here and replace it at your convenience.  The
+introductory text is available as a help page
+}}}
+[[IncludeHelpPage(Introduction)]]
diff --git a/inc/UUID.php b/inc/UUID.php
new file mode 100644 (file)
index 0000000..bcdb05e
--- /dev/null
@@ -0,0 +1,158 @@
+<?php # vim:ts=2:sw=2:noet:
+/* For licensing and copyright terms, see the file named LICENSE */
+/* Copyright (c) 2007, OmniTI Computer Consulting, Inc.
+ * All Rights Reserved.
+ */
+
+/** 
+ * A class for working with RFC 4122 UUIDs
+ */
+class OmniTI_Util_UUID {
+       public $binary;
+
+       function __construct($src = null) {
+               if ($src !== null) {
+                       switch (strlen($src)) {
+                               case 36: /* with -'s */
+                                       $src = str_replace('-', '', $src);
+                               case 32: /* with -'s stripped */
+                                       $this->binary = pack('H*', $src);
+                                       break;
+                               case 16: /* binary string */
+                                       $this->binary = $src;
+                                       break;
+                               case 24: /* base64 encoded binary */
+                                       $this->binary = base64_decode(
+                                               str_replace(
+                                                       array('@', '-', '_'),
+                                                       array('=', '/', '+'),
+                                                       $src));
+                                       break;
+                               default:
+                                       $this->binary = null;
+                       }
+               } else {
+                       $this->generate();
+               }
+       }
+
+       /** 
+        * returns a 32-bit integer that identifies this host.
+        * The node identifier needs to be unique among nodes
+        * in a cluster for a given application in order to
+        * avoid collisions between generated identifiers.
+        * You may extend and override this method if you
+        * want to substitute an alternative means of determining
+        * the node identifier */
+       protected function getNodeId() {
+               if (isset($_SERVER['SERVER_ADDR'])) {
+                       $node = ip2long($_SERVER['SERVER_ADDR']);
+               } else {
+                       /* running from the CLI most likely;
+                        * inspect the environment to see if we can
+                        * deduce the hostname, and from there, the
+                        * IP address */
+                       static $names = array('HOSTNAME', 'HOST');
+                       foreach ($names as $name) {
+                               if (isset($_ENV[$name])) {
+                                       $host = $_ENV[$name];
+                               } else {
+                                       $host = getenv($name);
+                               }
+                               if (strlen($host)) break;
+                       }
+                       if (!strlen($host)) {
+                               // punt 
+                               $node = ip2long('127.0.0.1');
+                       } else {
+                               $ip = gethostbyname($host);
+                               if (strlen($ip)) {
+                                       $node = ip2long($ip);
+                               } else {
+                                       // punt
+                                       $node = crc32($host);
+                               }
+                       }
+               }
+               return $node;
+       }
+
+       /** 
+        * returns a process identifier.
+        * In multi-process servers, this should be the system process ID.
+        * In multi-threaded servers, this should be some unique ID to
+        * prevent two threads from generating precisely the same UUID
+        * at the same time.
+        */
+       protected function getLockId() {
+               if (function_exists('zend_thread_id')) {
+                       return zend_thread_id();
+               }
+               return getmypid();
+       }
+
+       /** 
+        * generate an RFC 4122 UUID.
+        * This is psuedo-random UUID influenced by the system clock, IP
+        * address and process ID. 
+        *
+        * The intended use is to generate an identifier that can uniquely
+        * identify user generated posts, comments etc. made to a website.
+        * This generation process should be sufficient to avoid collisions
+        * between nodes in a cluster, and between apache children on the
+        * same host.
+        *
+        */
+       function generate() {
+               $node = $this->getNodeId();
+               $pid = $this->getLockId();
+
+               list($time_mid, $time_lo) = explode(' ', microtime());
+               $time_lo = (int)$time_lo;
+               $time_mid = (int)substr($time_mid, 2);
+
+               $time_hi = mt_rand(0, 0xfff);
+               /* version 4 UUID */
+               $time_hi |= 0x4000;
+
+               $clock_lo = mt_rand(0, 0xff);
+               $node_lo = $pid;
+
+               /* type is psuedo-random */
+               $clock_hi = mt_rand(0, 0x3f);
+               $clock_hi |= 0x80;
+
+               $this->binary = pack('NnnCCnN',
+                       $time_lo, $time_mid & 0xffff, $time_hi,
+                       $clock_hi, $clock_lo, $node_lo, $node);
+       }
+
+       /** 
+        * render the UUID as an RFC4122 standard string representation
+        * of the binary bits.
+        */
+       function toRFC4122String($dashes = true) {
+               $uuid = unpack('Ntl/ntm/nth/Cch/Ccl/nnl/Nn', $this->binary);
+               $fmt = $dashes ?
+                       "%08x-%04x-%04x-%02x%02x-%04x%08x" :
+                       "%08x%04x%04x%02x%02x%04x%08x";
+               return sprintf($fmt,
+                       $uuid['tl'], $uuid['tm'], $uuid['th'],
+                       $uuid['ch'], $uuid['cl'], $uuid['nl'], $uuid['n']);
+       }
+
+       /** 
+        * render the UUID using a modified base64 representation
+        * of the binary bits.  This string is shorter than the standard
+        * representation, but is not part of any standard specification.
+        */
+       function toShortString() {
+               return str_replace(
+                               array('=', '/', '+'),
+                               array('@', '-', '_'),
+                               base64_encode($this->binary));
+       }
+
+}
+
+?>
diff --git a/inc/acl.php b/inc/acl.php
new file mode 100644 (file)
index 0000000..f56adf4
--- /dev/null
@@ -0,0 +1,596 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackAuthorizationException extends Exception {
+  public $rights;
+  function __construct($msg, $rights) {
+    parent::__construct($msg);
+    $this->rights = $rights;
+  }
+}
+
+/* Each object in the system has an identifier, like 'ticket:XYZ' that
+ * indicates the type of object as well as its own identifier.
+ *
+ * Each object may also have a discressionary access control list (DACL) that
+ * describes what actions members of particular roles are permitted to
+ * the object.  The DACL can apply either to the object itself, or be
+ * a cascading (or inherited) entry that applies only to objects that are
+ * children of the object in question.
+ *
+ * When determining whether access is permitted, the DACL is walked from
+ * the object being accessed up to the root.  As soon as the allow/deny
+ * status us known for a specific (role, action) combination, the search
+ * stops.
+ *
+ * DACL entries can be explicitly ordered so that a particular user from
+ * a group can be excepted from a blanket allow/deny rule that follows.
+ */
+
+class MTrackACL {
+  static $cache = array();
+
+  static public function addRootObjectAndRoles($name) {
+    /* construct some roles that encapsulate read, modify, write */
+    $rolebase = preg_replace('/s$/', '', $name);
+
+    $ents = array(
+        array("{$rolebase}Viewer", "read", true),
+        array("{$rolebase}Editor", "read", true),
+        array("{$rolebase}Editor", "modify", true),
+        array("{$rolebase}Creator", "read", true),
+        array("{$rolebase}Creator", "modify", true),
+        array("{$rolebase}Creator", "create", true),
+        array("{$rolebase}Creator", "delete", true),
+        );
+    MTrackACL::setACL($name, true, $ents);
+    $ents = array(
+        array("{$rolebase}Viewer", "read", true),
+        array("{$rolebase}Editor", "read", true),
+        array("{$rolebase}Creator", "read", true),
+        array("{$rolebase}Creator", "modify", true),
+        array("{$rolebase}Creator", "create", true),
+        array("{$rolebase}Creator", "delete", true),
+        );
+    MTrackACL::setACL($name, false, $ents);
+  }
+
+  /* functions that we can call to determine ancestry */
+  static $genealogist = array();
+  static public function registerAncestry($objtype, $func) {
+    self::$genealogist[$objtype] = $func;
+  }
+
+  /* returns the objectid path that leads from the root to the specified
+   * object, including the object itself as the last element */
+  static public function getParentPath($objectid, $steps = -1)
+  {
+    $path = array();
+    while (strlen($objectid)) {
+      if ($steps != -1 && $steps-- == 0) {
+        break;
+      }
+      $path[] = $objectid;
+      if (isset(self::$genealogist[$objectid])) {
+        $func = self::$genealogist[$objectid];
+        if (is_string($func)) {
+          $parent = $func;
+        } else {
+          $parent = call_user_func($func, $objectid);
+        }
+        if (!$parent) break;
+        $objectid = $parent;
+        continue;
+      }
+      if (preg_match("/^(.*):([^:]+)$/", $objectid, $M)) {
+        $class = $M[1];
+        if (isset(self::$genealogist[$class])) {
+          $func = self::$genealogist[$class];
+          if (is_string($func)) {
+            $parent = $func;
+          } else {
+            $parent = call_user_func($func, $objectid);
+          }
+          if (!$parent) break;
+          $objectid = $parent;
+          continue;
+        }
+        $objectid = $class;
+        continue;
+      }
+      break;
+    }
+    return $path;
+  }
+
+  /* computes the overall ACL as it applies to someone that belongs to the
+   * indicated set of roles. */
+  static public function computeACL($objectid, $role_list)
+  {
+    $key = $objectid . '~' . join('~', $role_list);
+
+    if (isset(self::$cache[$key])) {
+      return self::$cache[$key];
+    }
+
+    /* we calculate the path to the object from its parent, and pull
+     * out all ACL entries on those objects that match the provided
+     * role list, ordering from the object up to the root.
+     */
+
+    $rlist = array();
+    $db = MTrackDB::get();
+    foreach ($role_list as $r => $rname) {
+      $rlist[] = $db->quote($r);
+    }
+    // Always want the special wildcard 'everybody' entry
+    $rlist[] = $db->quote('*');
+    $role_list = join(',', $rlist);
+
+    $actions = array();
+
+    $oidlist = array();
+    $path = self::getParentPath($objectid);
+    foreach ($path as $oid) {
+      $oidlist[] = $db->quote($oid);
+    }
+    $oidlist = join(',', $oidlist);
+
+    $sql = <<<SQL
+select objectid as id, action, cascade, allow
+from
+  acl
+where
+  role in ($role_list)
+  and objectid in ($oidlist)
+order by
+  cascade desc,
+  seq asc
+SQL
+    ;
+
+#    echo $sql;
+
+    # Collect the results and index by objectid; we'll need to walk over
+    # them in path order
+    $res_by_oid = array();
+
+    foreach (MTrackDB::q($sql)->fetchAll(PDO::FETCH_ASSOC) as $row) {
+      $res_by_oid[$row['id']][] = $row;
+    }
+    foreach ($path as $oid) {
+      if (!isset($res_by_oid[$oid])) continue;
+      foreach ($res_by_oid[$oid] as $row) {
+
+        if ($row['id'] == $objectid && $row['cascade']) {
+          /* ignore items below the object of interest */
+          continue;
+        }
+
+        if (!isset($actions[$row['action']])) {
+          $actions[$row['action']] = $row['allow'] ? true : false;
+        }
+      }
+    }
+
+    self::$cache[$key] = $actions;
+
+    return $actions;
+  }
+
+  /* Entries is an array of [role, action, allow] tuples in the order
+   * that they should be checked.
+   * If cascade is true, then these entries will replace the
+   * inheritable set, otherwise they will replace the entries
+   * on the object.
+   * If entries is an empty array, or not an array, then the appropriate
+   * ACL will be removed.
+   */
+  static public function setACL($object, $cascade, $entries)
+  {
+    self::$cache = array();
+
+    $cascade = (int)$cascade;
+    MTrackDB::q('delete from acl where objectid = ? and cascade = ?',
+      $object, $cascade);
+    $seq = 0;
+    if (is_array($entries)) {
+      foreach ($entries as $ent) {
+        if (isset($ent['role'])) {
+          $role = $ent['role'];
+          $action = $ent['action'];
+          $allow = $ent['allow'];
+        } else {
+          list($role, $action, $allow) = $ent;
+        }
+        MTrackDB::q('insert into acl (objectid, cascade, seq, role,
+              action, allow) values (?, ?, ?, ?, ?, ?)',
+            $object, $cascade, $seq++,
+            $role, $action, (int)$allow);
+      }
+    }
+  }
+
+  /* Obtains the ACL entries for the specified object.
+   * If cascade is true, it will return the inheritable ACL.
+   */
+  static public function getACL($object, $cascade)
+  {
+    return MTrackDB::q('select role, action, allow from acl
+      where objectid = ? and cascade = ? order by seq',
+      $object, (int)$cascade)->fetchAll(PDO::FETCH_ASSOC);
+  }
+
+  static public function hasAllRights($object, $rights)
+  {
+    if (MTrackAuth::getUserClass() == 'admin') {
+      return true;
+    }
+    if (!is_array($rights)) {
+      $rights = array($rights);
+    }
+    if (!count($rights)) {
+      throw new Exception("can't have all of no rights");
+    }
+    $acl = self::computeACL($object, MTrackAuth::getGroups());
+#    echo "ACL: $object<br>";
+#    var_dump($rights);
+#    echo "<br>";
+#    var_dump($acl);
+#    echo "<br>";
+
+    foreach ($rights as $action) {
+      if (!isset($acl[$action]) || !$acl[$action]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  static public function hasAnyRights($object, $rights)
+  {
+    if (MTrackAuth::getUserClass() == 'admin') {
+      return true;
+    }
+    if (!is_array($rights)) {
+      $rights = array($rights);
+    }
+    if (!count($rights)) {
+      throw new Exception("can't have any of no rights");
+    }
+    $acl = self::computeACL($object, MTrackAuth::getGroups());
+
+    $ok = false;
+    foreach ($rights as $action) {
+      if (isset($acl[$action]) && $acl[$action]) {
+        $ok = true;
+      }
+    }
+    return $ok;
+  }
+
+  static public function requireAnyRights($object, $rights)
+  {
+    if (!self::hasAnyRights($object, $rights)) {
+      throw new MTrackAuthorizationException("Not authorized", $rights);
+    }
+  }
+
+  static public function requireAllRights($object, $rights)
+  {
+    if (!self::hasAllRights($object, $rights)) {
+      throw new MTrackAuthorizationException("Not authorized", $rights);
+    }
+  }
+
+  /* helper for generating an ACL editor.
+   * As parameters, takes an objectid indicating the object being edited,
+   * and an action map which breaks down tasks into groups.
+   * Each group consists of a set of permissions, starting with the least
+   * permissive in that group, through to most permissive.
+   * Each group will be rendered as a select box, and a synthetic "none"
+   * entry will be generated for the group that explicitly excludes each
+   * of the other permission levels in that group.
+   *
+   * The form element that is generated will contain a JSON representation
+   * of an "ents" array that can be passed to setACL().
+   */
+  static public function renderACLForm($formprefix, $objectid, $map) {
+    $ident = preg_replace("/[^a-zA-Z]/", '', $formprefix);
+    $entities = array();
+    $groups = MTrackAuth::enumGroups();
+    /* merge in users */
+    foreach (MTrackDB::q('select userid, fullname from userinfo where active = 1')
+        ->fetchAll() as $row) {
+      if (isset($groups[$row[0]])) continue;
+      if (strlen($row[1])) {
+        $disp = "$row[0] - $row[1]";
+      } else {
+        $disp = $row[0];
+      }
+      $groups[$row[0]] = $disp;
+    }
+    if (!isset($groups['*'])) {
+      $groups['*'] = '(Everybody)';
+    }
+
+    // Encode the map into an object
+    $mobj = new stdClass;
+
+    $reng = array();
+    $rank = array();
+
+    foreach ($map as $group => $actions) {
+      // Each subsequent action in a group implies access greater than
+      // the item that preceeds it
+
+      $all_perms = array_keys($actions);
+      $prohibit = array();
+      foreach ($all_perms as $p) {
+        $prohibit[$p] = "-$p";
+      }
+      $none = join('|', $prohibit);
+      $a = array();
+      $a[] = array($none, 'None');
+      $accum = array();
+      $i = 0;
+      foreach ($actions as $perm => $label) {
+        $accum[] = $perm;
+        unset($prohibit[$perm]);
+        $p = join('|', array_merge($accum, $prohibit));
+        $a[] = array($p, $label);
+        /* save this for reverse engineering the right group in the current
+         * ACL data */
+        $reng[$perm] = $group;
+        $rank[$group][$perm] = $i++;
+      }
+      $mobj->{$group} = $a;
+    }
+    $mobj = json_encode($mobj);
+
+    $roledefs = new stdclass;
+    $acl = self::getACL($objectid, 0);
+    foreach ($acl as $ent) {
+      $group = $reng[$ent['action']];
+      $act = ($ent['allow'] ? '' : '-') . $ent['action'];
+      $roledefs->{$ent['role']}->{$group}[] = $act;
+
+      if (!isset($groups[$ent['role']])) {
+        $groups[$ent['role']] = $ent['role'];
+      }
+    }
+    $roledefs = json_encode($roledefs);
+
+    /* let's figure out the inherited ACL */
+    $path = self::getParentPath($objectid, 2);
+    $inherited = new stdclass;
+    if (count($path) == 2) {
+      $pacl = self::getACL($path[1], 1);
+      foreach ($pacl as $ent) {
+        // Not relevant per the specified action map
+        if (!isset($reng[$ent['action']])) continue;
+
+        $group = $reng[$ent['action']];
+        $act = ($ent['allow'] ? '' : '-') . $ent['action'];
+        $inherited->{$ent['role']}->{$group}[] = $act;
+
+        if (!isset($groups[$ent['role']])) {
+          $groups[$ent['role']] = $ent['role'];
+        }
+      }
+
+      // Inheritable set may not be specified directly in
+      // the same terms as the action_map, so we need to infer it
+      // Example: we may have read|modify leaving delete unspecified.
+      // We treat this as read|modify|-delete
+      foreach ($inherited as $role => $agroups) {
+        foreach ($agroups as $group => $actions) {
+          $highest = null;
+          foreach ($actions as $act) {
+            if ($act[0] == '-') continue;
+            if ($highest === null || $rank[$group][$act] > $highest) {
+              $highest = $rank[$group][$act];
+              $hact = $act;
+            }
+          }
+          if ($highest === null) {
+            unset($inherited->{$role}->{$group});
+            continue;
+          }
+          // Compute full value
+          $comp = array();
+          foreach ($rank[$group] as $act => $i) {
+            if ($i <= $highest) {
+              $comp[] = $act;
+            } else {
+              $comp[] = "-$act";
+            }
+          }
+          $inherited->{$role}->{$group} = join('|', $comp);
+        }
+      }
+    }
+    $inherited = json_encode($inherited);
+
+    //var_dump($acl);
+
+    $groups = json_encode($groups);
+    $cat_order = json_encode(array_keys($map));
+
+    echo <<<HTML
+<div class='permissioneditor'>
+<p>
+  <b>Permissions</b>
+</p>
+<p>
+  <em>Select "Add" to define permissions for an entity.
+    The first matching permission is taken as definitive,
+    so if a given user belongs to multiple groups and matches
+    multiple permission rows, the first is taken.  You may
+    drag to re-order permissions.
+  </em>
+</p>
+<p>
+  <em>Permissions inherited from the parent of this object are
+  shown as non-editable entries at the top of the list. You may
+  override them by adding your own explicit entry.</em>
+</p>
+<br>
+<input type='hidden' id='$formprefix' name='$formprefix'>
+<table id='acl$ident'>
+  <thead>
+    <tr>
+      <th>Entity</th>
+    </tr>
+  </thead>
+  <tbody></tbody>
+</table>
+<script>
+$(document).ready(function () {
+  var cat_order = $cat_order;
+  var groups = $groups;
+  var roledefs = $roledefs;
+  var inherited = $inherited;
+  var mobj = $mobj;
+  var disp = $('#acl$ident');
+  var tbody = $('tbody', disp);
+  var sel;
+  var field = $('#$formprefix');
+
+  function add_acl_entity(role)
+  {
+    // Delete role from select box
+    $('option', sel).each(function () {
+      if ($(this).attr('value') == role) {
+        $(this).remove();
+      }
+    });
+    // Create a row for this role
+    var sp = $('<tr style="cursor:pointer"/>');
+    sp.append(
+      $('<td/>')
+        .html('<span style="position: absolute; margin-left: -1.3em" class="ui-icon ui-icon-arrowthick-2-n-s"></span>')
+        .append(groups[role])
+    );
+    tbody.append(sp);
+
+    for (var gi in cat_order) {
+      var group = cat_order[gi];
+      var gsel = $('<select/>');
+      gsel.data('acl.role', role);
+      var data = mobj[group];
+      for (var i in data) {
+        var a = data[i];
+        gsel.append(
+          $('<option/>')
+            .attr('value', a[0])
+            .text(a[1])
+          );
+      }
+      if (roledefs[role]) {
+        gsel.val(roledefs[role][group].join('|'));
+      }
+      sp.append(
+        $('<td/>')
+          .append(gsel)
+      );
+    }
+    var b = $('<button>x</button>');
+    sp.append(
+      $('<td/>')
+        .append(b)
+    );
+    b.click(function () {
+      sp.remove();
+      sel.append(
+        $('<option/>')
+          .attr('value', role)
+          .text(groups[role])
+      );
+    });
+  }
+
+  var tr = $('thead tr', disp);
+  // Add columns for action groups
+  for (var gi in cat_order) {
+    var group = cat_order[gi];
+    tr.append($('<th/>').text(group));
+  }
+  // Add fixed inherited rows
+  var thead = $('thead', disp);
+  for (var role in inherited) {
+    tr = $('<tr class="inheritedacl"/>');
+    tr.append($('<td/>').text(groups[role]));
+    for (var group in mobj) {
+      var d = inherited[role][group];
+      if (d) {
+        // Good old fashioned look up (we don't have this hashed)
+        for (var i in mobj[group]) {
+          var ent = mobj[group][i];
+          if (ent[0] == d) {
+            d = ent[1];
+            break;
+          }
+        }
+        tr.append($('<td/>').text(d));
+      } else {
+        tr.append($('<td>(Not Specified)</td>'));
+      }
+    }
+    thead.append(tr);
+  }
+  sel = $('<select/>');
+  sel.append(
+    $('<option/>')
+      .text('Add...')
+  );
+
+  for (var i in groups) {
+    var g = groups[i];
+    sel.append(
+      $('<option/>')
+        .attr('value', i)
+        .text(g)
+    );
+  }
+  disp.append(sel);
+  /* make the tbody sortable. Note that we append the "Add..." to the table,
+   * not the tbody, so that we don't allow dragging it around */
+  tbody.sortable();
+
+  for (var role in roledefs) {
+    add_acl_entity(role);
+  }
+
+  sel.change(function () {
+    var v = sel.val();
+    if (v && v.length) {
+      add_acl_entity(v);
+    }
+  });
+
+  field.parents('form:first').submit(function () {
+    var acl = [];
+    $('select', tbody).each(function () {
+      var role = $(this).data('acl.role');
+      var val = $(this).val().split('|');
+      for (var i in val) {
+        var action = val[i];
+        var allow = 1;
+        if (action.substring(0, 1) == '-') {
+          allow = 0;
+          action = action.substring(1);
+        }
+        acl.push([role, action, allow]);
+      }
+    });
+    field.val(JSON.stringify(acl));
+  });
+});
+</script>
+</div>
+HTML;
+
+  }
+}
+
diff --git a/inc/attachment.php b/inc/attachment.php
new file mode 100644 (file)
index 0000000..bca1fc4
--- /dev/null
@@ -0,0 +1,213 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackAttachment {
+
+  static function add($object, $local_filename, $filename,
+      MTrackChangeset $CS)
+  {
+    $size = filesize($local_filename);
+    if (!$size) {
+      return false;
+    }
+    $hash = self::import_file($local_filename);
+    $fp = fopen($local_filename, 'rb');
+    $q = MTrackDB::get()->prepare(
+      'insert into attachments (object, hash, filename, size, cid, payload)
+      values (?, ?, ?, ?, ?, ?)');
+    $q->bindValue(1, $object);
+    $q->bindValue(2, $hash);
+    $q->bindValue(3, $filename);
+    $q->bindValue(4, $size);
+    $q->bindValue(5, $CS->cid);
+    $q->bindValue(6, $fp, PDO::PARAM_LOB);
+    $q->execute();
+    $CS->add("$object:@attachment:", '', $filename);
+  }
+
+  static function process_delete($relobj, MTrackChangeset $CS) {
+    if (!isset($_POST['delete_attachment'])) return;
+    if (!is_array($_POST['delete_attachment'])) return;
+    foreach ($_POST['delete_attachment'] as $name) {
+      $vars = explode('/', $name);
+      $filename = array_pop($vars);
+      $cid = array_pop($vars);
+      $object = join('/', $vars);
+
+      if ($object != $relobj) return;
+      MTrackDB::q('delete from attachments where object = ? and
+          cid = ? and filename = ?', $object, $cid, $filename);
+      $CS->add("$object:@attachment:", $filename, '');
+    }
+  }
+
+  /* this function is registered into sqlite and invoked from
+   * a trigger whenever an attachment row is deleted */
+  static function attachment_row_deleted($hash, $count)
+  {
+    if ($count == 0) {
+      // unlink the underlying file here
+      unlink(self::local_path($hash, false));
+    }
+    return $count;
+  }
+
+  static function hash_file($filename)
+  {
+    return sha1_file($filename);
+  }
+
+  static function local_path($hash, $fetch = true)
+  {
+    $adir = MTrackConfig::get('core', 'vardir') . '/attach';
+
+    /* 40 hex digits: split into 16, 16, 4, 4 */
+    $a = substr($hash, 0, 16);
+    $b = substr($hash, 16, 16);
+    $c = substr($hash, 32, 4);
+    $d = substr($hash, 36, 4);
+
+    $dir = "$adir/$a/$b/$c";
+    if (!is_dir($dir)) {
+      $mask = umask(0);
+      mkdir($dir, 02777, true);
+      umask($mask);
+    }
+    $filename = $dir . "/$d";
+
+    if ($fetch) {
+      // Tricky locking bit
+      $fp = @fopen($filename, 'c+');
+      flock($fp, LOCK_EX);
+      $st = fstat($fp);
+      if ($st['size'] == 0) {
+        /* we get to fill it out */
+
+        $db = MTrackDB::get();
+        $q = $db->prepare(
+            'select payload from attachments where hash = ?');
+        $q->execute(array($hash));
+        $q->bindColumn(1, $blob, PDO::PARAM_LOB);
+        $q->fetch();
+        if (is_string($blob)) {
+          fwrite($fp, $blob);
+        } else {
+          stream_copy_to_stream($blob, $fp);
+        }
+        rewind($fp);
+      }
+    }
+
+    return $filename;
+  }
+
+  /* calculates the hash of the filename.  If another file with
+   * the same hash does not already exist in the attachment area,
+   * the file is copied in.
+   * Returns the hash */
+  static function import_file($filename)
+  {
+    $h = self::hash_file($filename);
+    $dest = self::local_path($h, false);
+    if (!file_exists($dest)) {
+      if (is_uploaded_file($filename)) {
+        move_uploaded_file($filename, $dest);
+      } else if (!is_file($filename)) {
+        throw new Exception("$filename does not exist");
+      } else {
+        copy($filename, $dest);
+      }
+    }
+    return $h;
+  }
+
+  static function renderDeleteList($object)
+  {
+    global $ABSWEB;
+
+    $atts = MTrackDB::q('
+      select * from attachments
+      left join changes on (attachments.cid = changes.cid)
+      where attachments.object = ? order by changedate, filename',
+        $object)->fetchAll(PDO::FETCH_ASSOC);
+
+    if (count($atts) == 0) return '';
+
+    $max_dim = 150;
+
+    $html = <<<HTML
+<em>Select the checkbox to delete an attachment</em>
+<table>
+  <tr>
+    <td>&nbsp;</td>
+    <td>Attachment</td>
+    <td>Size</td>
+    <td>Added</td>
+  </tr>
+HTML;
+
+    foreach ($atts as $row) {
+      $url = "{$ABSWEB}attachment.php/$object/$row[cid]/$row[filename]";
+      $html .= <<<HTML
+<tr>
+  <td><input type='checkbox' name='delete_attachment[]'
+      value='$object/$row[cid]/$row[filename]'></td>
+  <td><a class='attachment' href='$url'>$row[filename]</a></td>
+  <td>$row[size]</td>
+  <td>
+HTML;
+      $html .= mtrack_username($row['who'], array(
+          'no_image' => true
+        )) .
+        " " . mtrack_date($row['changedate']) . "</td></tr>\n";
+    }
+    $html .= "</table><br>";
+    return $html;
+  }
+
+  /* renders the attachment list for a given object */
+  static function renderList($object)
+  {
+    global $ABSWEB;
+
+    $atts = MTrackDB::q('
+      select * from attachments
+      left join changes on (attachments.cid = changes.cid)
+      where attachments.object = ? order by changedate, filename',
+        $object)->fetchAll(PDO::FETCH_ASSOC);
+
+    if (count($atts) == 0) return '';
+
+    $max_dim = 150;
+
+    $html = "<div class='attachment-list'><b>Attachments</b><ul>";
+    foreach ($atts as $row) {
+      $url = "{$ABSWEB}attachment.php/$object/$row[cid]/$row[filename]";
+      $html .= "<li><a class='attachment'" .
+        " href='$url'>".
+        "$row[filename]</a> ($row[size]) added by " .
+        mtrack_username($row['who'], array(
+          'no_image' => true
+        )) .
+        " " . mtrack_date($row['changedate']);
+
+      list($width, $height) = getimagesize(self::local_path($row['hash']));
+      if ($width + $height) {
+        /* limit maximum size */
+        if ($width > $max_dim) {
+          $height *= $max_dim / $width;
+          $width = $max_dim;
+        }
+        if ($height > $max_dim) {
+          $width *= $max_dim / $height;
+          $height = $max_dim;
+        }
+        $html .= "<br><a href='$url'><img src='$url' width='$width' border='0' height='$height'></a>";
+      }
+
+      $html .= "</li>\n";
+    }
+    $html .= "</ul></div>";
+    return $html;
+  }
+}
diff --git a/inc/auth.php b/inc/auth.php
new file mode 100644 (file)
index 0000000..749f41e
--- /dev/null
@@ -0,0 +1,312 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include_once MTRACK_INC_DIR . '/auth/http.php';
+include_once MTRACK_INC_DIR . '/auth/openid.php';
+
+interface IMTrackAuth {
+  /** Returns the authenticated user, or null if authentication is
+   * required */
+  function authenticate();
+
+  /** Called if the user is not authenticated as a registered
+   * user and if the page requires it.
+   * Should initiate whatever is appropriate to begin the authentication
+   * process (eg: displaying logon information).
+   * You may assume that no output has been sent to the client at
+   * the time that this function is called.
+   * Returns null if not supported, throw an exception if failed,
+   * else return a the authenticated user (if it can be determined
+   * by the time the function returns).
+   * If an alternate login page is displayed, this function should
+   * exit instead of returning.
+   */
+  function doAuthenticate($force = false);
+
+  /** Returns a list of available groups.
+   * Returns null if not supported, throw an exception if failed. */
+  function enumGroups();
+
+  /** Returns a list of groups that a given user belongs to.
+   * Returns null if not supported, throw an exception if failed. */
+  function getGroups($username);
+
+  /** Adds a user to a group.
+   * Returns null if not supported, throw an exception if failed,
+   * return true if succeeded */
+  function addToGroup($username, $groupname);
+
+  /** Removes a user from a group.
+   * Returns null if not supported, throw an exception if failed,
+   * return true if succeeded */
+  function removeFromGroup($username, $groupname);
+
+  /** Returns userdata for a given user id
+   * Some authentication mechanisms outsource the storage of user data.
+   * This function returns null if no additional information is available,
+   * or an array containing the following keys:
+   *   email - the email address
+   *   fullname - the full name
+   *   avatar - URL to an avatar image
+   */
+  function getUserData($username);
+}
+
+class MTrackAuth
+{
+  static $stack = array();
+  static $mechs = array();
+  static $group_assoc = array();
+
+  public static function registerMech(IMTrackAuth $mech) {
+    self::$mechs[] = $mech;
+  }
+
+  /** switch user */
+  public static function su($user) {
+    if (!strlen($user)) throw new Exception("invalid user");
+    array_unshift(self::$stack, $user);
+  }
+
+  /** returns the instance of an auth mechanism given its class name */
+  public static function getMech($name) {
+    foreach (self::$mechs as $inst) {
+      if ($inst instanceof $name) {
+        return $inst;
+      }
+    }
+    return null;
+  }
+
+  /** drop identity set by last su */
+  public static function drop() {
+    if (count(self::$stack) == 0) {
+      throw new Exception("no privs to drop");
+    }
+    return array_shift(self::$stack);
+  }
+
+  /** returns the authenticated user, or null if authentication
+   * is required */
+  public static function authenticate() {
+    foreach (self::$mechs as $mech) {
+      $name = $mech->authenticate();
+      if ($name !== null) {
+        return $name;
+      }
+    }
+
+    /* always fall back on the unix username when running from
+     * the console */
+    if (php_sapi_name() == 'cli') {
+      static $envs = array('MTRACK_LOGNAME', 'LOGNAME', 'USER');
+      foreach ($envs as $name) {
+        if (isset($_ENV[$name])) {
+          return $_ENV[$name];
+        }
+      }
+    } elseif (count(self::$mechs) == 0 &&
+        MTrackConfig::get('core', 'admin_party') == 1
+        && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' ||
+          $_SERVER['REMOTE_ADDR'] == '::1')) {
+      return 'adminparty';
+    }
+
+    return null;
+  }
+
+  public static function isAuthConfigured() {
+    return count(self::$mechs) ? true : false;
+  }
+
+  /** determine the current identity.  If doauth is true (default),
+   * then the authentication hook will be invoked */
+  public static function whoami($doauth = true) {
+    if (count(self::$stack) == 0 && $doauth) {
+      try {
+        $who = self::authenticate();
+        if ($who === null) {
+          foreach (self::$mechs as $mech) {
+            $who = $mech->doAuthenticate();
+            if ($who !== null) {
+              break;
+            }
+          }
+        }
+        if ($who !== null) {
+          self::su($who);
+        }
+      } catch (Exception $e) {
+        if (php_sapi_name() != 'cli') {
+          header('HTTP/1.0 401 Unauthorized');
+          echo "<h1>Not authorized</h1>";
+          echo htmlentities($e->getMessage());
+        } else {
+          echo " ** Not authorized\n\n";
+          echo $e->getMessage() . "\n";
+        }
+        error_log($e->getMessage());
+        exit(1);
+      }
+    }
+    if (!count(self::$stack)) {
+      return "anonymous";
+    }
+    return self::$stack[0];
+  }
+
+  static function getUserClass($user = null) {
+    if ($user === null) {
+      $user = self::whoami();
+    }
+    if (MTrackConfig::get('core', 'admin_party') == 1
+        && $user == 'adminparty'
+        && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' ||
+          $_SERVER['REMOTE_ADDR'] == '::1')) {
+      return 'admin';
+    }
+
+    $user_class = MTrackConfig::get('user_classes', $user);
+    if ($user_class === null) {
+      if ($user == 'anonymous') {
+        return 'anonymous';
+      }
+      return 'authenticated';
+    }
+    return $user_class;
+  }
+
+  static $userdata_cache = array();
+  static function getUserData($username) {
+    $username = mtrack_canon_username($username);
+
+    if (array_key_exists($username, self::$userdata_cache)) {
+      return self::$userdata_cache[$username];
+    }
+    $data = null;
+    foreach (self::$mechs as $mech) {
+      $data = $mech->getUserData($username);
+      if ($data !== null) {
+        break;
+      }
+    }
+    if ($data === null) {
+      foreach (MTrackDB::q(
+          'select fullname, email from userinfo where userid = ?',
+          $username)->fetchAll(PDO::FETCH_ASSOC) as $row) {
+        $data = $row;
+        break;
+      }
+    }
+    if ($data === null) {
+      $data = array(
+        'fullname' => $username
+      );
+    }
+
+    if (!isset($data['email'])) {
+      if (preg_match('/<([a-z0-9_.+=-]+@[a-z0-9.-]+)>/', $username, $M)) {
+        // username contains an email address
+        $data['email'] = $M[1];
+      } else if (preg_match('/^([a-z0-9_.+=-]+@[a-z0-9.-]+)$/', $username)) {
+        // username is an email address
+        $data['email'] = $username;
+      } else if (preg_match('/^[a-z0-9_.+=-]+$/', $username)) {
+        // valid localpart; assume a domain and construct an email address
+        $dom = MTrackConfig::get('core', 'default_email_domain');
+        if ($dom !== null) {
+          $data['email'] = $username . '@' . $dom;
+        }
+      }
+    }
+
+    self::$userdata_cache[$username] = $data;
+
+    return $data;
+  }
+
+  /* enumerates possible groups from the auth plugin layer */
+  static function enumGroups() {
+    $groups = array();
+    foreach (self::$mechs as $mech) {
+      $g = $mech->enumGroups();
+      if (is_array($g)) {
+        foreach ($g as $i => $grp) {
+          if (is_integer($i)) {
+            $groups[$grp] = $grp;
+          } else {
+            $groups[$i] = $grp;
+          }
+        }
+      }
+    }
+    /* merge in our project groups */
+    foreach (MTrackDB::q('select project, g.name, p.name from groups g left join projects p on g.project = p.projid')
+        as $row) {
+      $gid = "project:$row[0]:$row[1]";
+      $groups[$gid] = "$row[1] ($row[2])";
+    }
+    return $groups;
+  }
+
+  /* returns groups of which the authenticated user is a member */
+  static function getGroups($user = null) {
+    if ($user === null) {
+      $user = self::whoami();
+    }
+    $canon = mtrack_canon_username($user);
+
+    if (isset(self::$group_assoc[$user])) {
+      return self::$group_assoc[$user];
+    }
+
+    $roles = array($canon => $canon);
+
+    $user_class = self::getUserClass($user); // FIXME: $canon?
+    $class_roles = MTrackConfig::get('user_class_roles', $user_class);
+    foreach (preg_split('/\s*,\s*/', $class_roles) as $role) {
+      $roles[$role] = $role;
+    }
+
+    foreach (self::$mechs as $mech) {
+      $g = $mech->getGroups($user);
+      if (is_array($g)) {
+        foreach ($g as $i => $grp) {
+          if (is_integer($i)) {
+            $roles[$grp] = $grp;
+          } else {
+            $roles[$i] = $grp;
+          }
+        }
+      }
+    }
+    /* merge in our project group membership */
+    foreach (MTrackDB::q('select project, groupname, p.name from group_membership gm left join projects p on gm.project = p.projid where username = ?',
+        $canon)->fetchAll() as $row) {
+      $gid = "project:$row[0]:$row[1]";
+      $roles[$gid] = "$row[1] ($row[2])";
+    }
+
+    self::$group_assoc[$user] = $roles;
+    return $roles;
+  }
+
+  static function forceAuthenticate() {
+    try {
+      $who = self::authenticate();
+      if ($who === null) {
+        foreach (self::$mechs as $mech) {
+          $who = $mech->doAuthenticate(true);
+          if ($who !== null) {
+            break;
+          }
+        }
+      }
+      if ($who !== null) {
+        self::su($who);
+      }
+    } catch (Exception $e) {
+    }
+  }
+}
+
diff --git a/inc/auth/http.php b/inc/auth/http.php
new file mode 100644 (file)
index 0000000..0fbcd6b
--- /dev/null
@@ -0,0 +1,329 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackAuth_HTTP implements IMTrackAuth {
+  public $htgroup = null;
+  public $htpasswd = null;
+  public $use_digest = false;
+  public $realm = 'mtrack';
+
+  function __construct($group = null, $passwd = null) {
+    $this->htgroup = $group;
+    if ($passwd !== null) {
+      if (!strncmp('digest:', $passwd, 7)) {
+        $this->use_digest = true;
+        $passwd = substr($passwd, 7);
+      }
+      $this->htpasswd = $passwd;
+    }
+    MTrackAuth::registerMech($this);
+  }
+
+  function parseDigest($string)
+  {
+    $resp = trim($string);
+    $DIG = array();
+    while (strlen($resp)) {
+      if (!preg_match('/^([a-z-]+)\s*=\s*(.*)$/', $resp, $M)) {
+#        error_log("unable to parse $string [$resp]");
+        return null;
+      }
+      $name = $M[1];
+      $param = null;
+
+      $rest = $M[2];
+
+      if ($rest[0] == '"' || $rest[0] == "'") {
+        $delim = $rest[0];
+        $delim_offset = 1;
+      } else {
+        $delim = ',';
+        $delim_offset = 0;
+      }
+      $len = strlen($rest);
+      $i = $delim_offset;
+      while ($i < $len) {
+        if ($delim != ',' && $rest[$i] == '\\') {
+          $i += 2;
+          if ($i >= $len) {
+#            error_log("unable to parse $string (unterminated quotes)");
+            return null;
+          }
+          continue;
+        }
+        if ($rest[$i] == $delim) {
+          $param = substr($rest, $delim_offset, $i - $delim_offset);
+          $resp = substr($rest, $i + 1);
+          break;
+        }
+        $i++;
+      }
+      if ($param === null && $delim != ',') {
+#        error_log("unable to parse $string, unterminated delim $delim");
+        return null;
+      }
+      if ($param === null) {
+        $param = $rest;
+        $resp = '';
+      }
+      $DIG[$name] = $param;
+
+      if (preg_match('/^,\s*(.*)$/', $resp, $M)) {
+        $resp = $M[1];
+      }
+      $resp = trim($resp);
+    }
+    return $DIG;
+  }
+
+  /* Leave authentication to the web server configuration */
+  function authenticate() {
+    /* web server based auth */
+    if (isset($_SERVER['REMOTE_USER'])) {
+      return $_SERVER['REMOTE_USER'];
+    }
+
+    /* PHP based auth */
+    if (($this->use_digest && isset($_SERVER['PHP_AUTH_DIGEST'])) ||
+        (!$this->use_digest && isset($_SERVER['PHP_AUTH_USER'])))
+    {
+      /* validate the password */
+      if ($this->use_digest) {
+        /* parse the digest response */
+
+        $DIG = $this->parseDigest($_SERVER['PHP_AUTH_DIGEST']);
+
+        if ($DIG['nc'] != '00000001') {
+          // only allow a nonce-count of 1
+          return null;
+        }
+        if ($DIG['realm'] != $this->realm) {
+          return null;
+        }
+        $secret = $this->getSecret();
+        global $ABSWEB;
+        $domain = $ABSWEB;
+        $opaque = sha1($domain . $secret);
+
+        if ($DIG['opaque'] != $opaque) {
+          // secret expired
+          error_log("secret expired");
+          return null;
+        }
+
+        $user = $DIG['username'];
+
+      } else {
+        $user = $_SERVER['PHP_AUTH_USER'];
+      }
+
+      if (!strlen($user)) {
+        return null;
+      }
+
+      if ($this->htpasswd === null) {
+        error_log("no password file defined, unable to validate $user");
+        return null;
+      }
+
+      $fp = fopen($this->htpasswd, 'r');
+      if (!$fp) {
+        error_log("unable to open password file to validate user $user");
+        return null;
+      }
+
+      if (!flock($fp, LOCK_SH)) {
+        error_log("unable to lock password file to validate user $user");
+        return null;
+      }
+
+      $puser = preg_quote($user);
+      $correct_password = null;
+
+      while (true) {
+        $line = fgets($fp);
+        if ($line === false) {
+          $user = false;
+          break;
+        }
+
+        if ($this->use_digest) {
+          if (preg_match("/^$puser:(.*):(.*)$/", $line, $M)) {
+            if ($M[1] != $this->realm) {
+              continue;
+            }
+            // $M[2] is: md5($user . ":" . $realm . ":" . $pw)
+            $expect = $M[2];
+            $uri = md5($_SERVER['REQUEST_METHOD'] . ':' . $DIG['uri']);
+            $resp = md5("$expect:$DIG[nonce]:$DIG[nc]:$DIG[cnonce]:$DIG[qop]:$uri");
+            if ($resp != $DIG['response']) {
+              /* invalid */
+              $user = null;
+            }
+            break;
+          }
+        } else {
+          if (preg_match("/^$puser\s*:\s*(\S+)/", $line, $M)) {
+            if (crypt($_SERVER['PHP_AUTH_PW'], $M[1]) != $M[1]) {
+              /* invalid */
+              $user = null;
+            }
+            break;
+          }
+        }
+      }
+      flock($fp, LOCK_UN);
+      $fp = null;
+
+      return $user;
+    }
+
+    return null;
+  }
+
+  function getSecret() {
+    $secret_file = MTrackConfig::get('core', 'vardir') . '/.digest.secret';
+    if (file_exists($secret_file)) {
+      if (filemtime($secret_file) + 300 > time()) {
+        $res = file_get_contents($secret_file);
+        if ($res === false) {
+          error_log(
+            "Unable to read HTTP secret for mtrack; logins will likely fail");
+        }
+        return $res;
+      }
+      unlink($secret_file);
+    }
+    $secret = uniqid();
+    if (!file_put_contents($secret_file, $secret)) {
+      error_log(
+        "Unable to write HTTP secret for mtrack; logins will likely fail");
+    }
+    return $secret;
+  }
+
+  function doAuthenticate($force = false) {
+    /* This is only triggered if the web server isn't configured
+     * to handle auth itself */
+
+    $realm = $this->realm;
+
+    if ($this->use_digest) {
+      $secret = $this->getSecret();
+      $nonce = sha1(uniqid() . $secret);
+      global $ABSWEB;
+      $domain = $ABSWEB;
+      $opaque = sha1($domain . $secret);
+      header("WWW-Authenticate: Digest realm=\"$realm\",qop=\"auth\",nonce=\"$nonce\",opaque=\"$opaque\"");
+    } else {
+      header("WWW-Authenticate: Basic realm=\"$realm\"");
+    }
+    header('HTTP/1.0 401 Unauthorized');
+
+?>
+<h1>Authentication Required</h1>
+
+<p>I need to know who you are to allow you to access to this site.</p>
+<?php
+    exit;
+  }
+
+  protected function readGroupFile($filename) {
+    if (!file_exists($filename)) return null;
+    $fp = fopen($filename, 'r');
+    if (!$fp) return null;
+    if (!flock($fp, LOCK_SH)) return null;
+
+    /* an apache style group file */
+    $groups = array();
+    $users = array();
+
+    while (true) {
+      $line = fgets($fp);
+      if ($line === false) {
+        break;
+      }
+      $line = trim($line);
+      if ($line[0] == '#') {
+        continue;
+      }
+      if (preg_match('/^([a-zA-Z][a-zA-Z0-9_]+)\s*:\s*(.*)$/', $line,
+            $M)) {
+        $groupname = $M[1];
+        $members = $M[2];
+        foreach (preg_split('/\s+/', $members) as $user) {
+          $users[$user][] = $groupname;
+          $groups[$groupname][] = $user;
+        }
+      }
+    }
+
+    flock($fp, LOCK_UN);
+    $fp = null;
+    return array($groups, $users);
+  }
+
+  function enumGroups() {
+    if (strlen($this->htgroup)) {
+      list($groups, $users) = $this->readGroupFile($this->htgroup);
+      return array_keys($groups);
+    }
+    return null;
+  }
+
+  function getGroups($username) {
+    if (strlen($this->htgroup)) {
+      list($groups, $users) = $this->readGroupFile($this->htgroup);
+      return $users[$username];
+    }
+    return null;
+  }
+
+  function addToGroup($username, $groupname)
+  {
+    return null;
+  }
+
+  function removeFromGroup($username, $groupname)
+  {
+    return null;
+  }
+
+  function getUserData($username) {
+    return null;
+  }
+
+  /** a bit of a hack; this helper enables the HTTP password to be set
+   * by the user admin screen */
+  function setUserPassword($username, $password) {
+    if (!$this->use_digest) {
+      throw new Exception("not supported");
+    }
+    $pwline = "mtrack:" .
+      md5("$username:mtrack:" . $password);
+    $fp = fopen($this->htpasswd, 'r+');
+    if (!$fp && !file_exists($this->htpasswd)) {
+      $fp = fopen($this->htpasswd, 'w');
+    }
+    if (!$fp) {
+      throw new Exception("failed to write to $this->htpasswd");
+    }
+    flock($fp, LOCK_EX);
+    $lines = array();
+    while (($line = fgets($fp)) !== false) {
+      $bits = explode(':', $line, 2);
+      if (count($bits) >= 2) {
+        $lines[$bits[0]] = $bits[1];
+      }
+    }
+    $lines[$username] = $pwline;
+    fseek($fp, 0);
+    ftruncate($fp, 0);
+    foreach ($lines as $user => $rest) {
+      fwrite($fp, "$user:$rest\n");
+    }
+    flock($fp, LOCK_UN);
+    $fp = null;
+  }
+}
+
diff --git a/inc/auth/openid.php b/inc/auth/openid.php
new file mode 100644 (file)
index 0000000..69a8d4d
--- /dev/null
@@ -0,0 +1,66 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+/* all the clever stuff happens in openid.php */
+class MTrackAuth_OpenID implements IMTrackAuth, IMTrackNavigationHelper {
+  function __construct() {
+    MTrackAuth::registerMech($this);
+    MTrackNavigation::registerHelper($this);
+  }
+
+  function augmentUserInfo(&$content) {
+    global $ABSWEB;
+    if (isset($_SESSION['openid.id'])) {
+      $content .= " | <a href='{$ABSWEB}openid.php/signout'>Log off</a>";
+    } else {
+      $content = "<a href='{$ABSWEB}openid.php'>Log In</a>";
+    }
+  }
+
+  function augmentNavigation($id, &$items) {
+  }
+
+  function authenticate() {
+    if (!strlen(session_id()) && php_sapi_name() != 'cli') {
+      session_start();
+    }
+    if (isset($_SESSION['openid.id'])) {
+      if (isset($_SESSION['openid.userid'])) {
+        return $_SESSION['openid.userid'];
+      }
+      return $_SESSION['openid.id'];
+    }
+    return null;
+  }
+
+  function doAuthenticate($force = false) {
+    if ($force) {
+      global $ABSWEB;
+      header("Location: {$ABSWEB}openid.php");
+      exit;
+    }
+    return null;
+  }
+
+  function enumGroups() {
+    return null;
+  }
+
+  function getGroups($username) {
+    return null;
+  }
+
+  function addToGroup($username, $groupname) {
+    return null;
+  }
+
+  function removeFromGroup($username, $groupname) {
+    return null;
+  }
+
+  function getUserData($username) {
+    return null;
+  }
+}
+
+
diff --git a/inc/cache.php b/inc/cache.php
new file mode 100644 (file)
index 0000000..b6dd4c7
--- /dev/null
@@ -0,0 +1,155 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+/* maintain cache */
+function mtrack_cache_maintain()
+{
+  $cachedir = MTrackConfig::get('core', 'vardir') . '/cmdcache';
+  $max_cache_life = MTrackConfig::get('core', 'max_cache_life');
+  if (!$max_cache_life) {
+    $max_cache_life = 14 * 86400;
+  }
+  foreach (scandir($cachedir) as $name) {
+    $filename = "$cachedir/$name";
+    if (!is_file($filename)) {
+      continue;
+    }
+    $st = stat($filename);
+    if ($st['mtime'] + $max_cache_life < time()) {
+      unlink($filename);
+    }
+  }
+}
+
+/* walks the cache; loads each element and examines the keys.
+ * if the key prefix matches $key, that element is removed */
+function mtrack_cache_blow_matching($key)
+{
+  $cachedir = MTrackConfig::get('core', 'vardir') . '/cmdcache';
+  foreach (scandir($cachedir) as $name) {
+    $filename = "$cachedir/$name";
+    if (!is_file($filename)) {
+      continue;
+    }
+    $fp = @fopen($filename, 'r');
+    if (!$fp) {
+      continue;
+    }
+    flock($fp, LOCK_SH);
+    $data = unserialize(stream_get_contents($fp));
+    flock($fp, LOCK_UN);
+    $fp = null;
+
+    $match = true;
+    foreach ($key as $i => $element) {
+      if ($data->key[$i] != $element) {
+        $match = false;
+        break;
+      }
+    }
+    if ($match) {
+      unlink("$cachedir/$name");
+    }
+  }
+}
+
+function mtrack_cache_blow($key)
+{
+  $cachedir = MTrackConfig::get('core', 'vardir') . '/cmdcache';
+  foreach (scandir($cachedir) as $name) {
+    if (!is_file($name)) {
+      continue;
+    }
+    $fp = @fopen("$cachedir/$name", 'r');
+    if (!$fp) {
+      continue;
+    }
+    flock($fp, LOCK_SH);
+    $data = unserialize(stream_get_contents($fp));
+    flock($fp, LOCK_UN);
+    $fp = null;
+
+    if ($key == $data->key) {
+      unlink("$cachedir/$name");
+    }
+  }
+}
+
+function mtrack_cache($func, $args, $cache_life = 300, $key = null)
+{
+  $cachedir = MTrackConfig::get('core', 'vardir') . '/cmdcache';
+  if (!is_dir($cachedir)) {
+    mkdir($cachedir);
+  }
+  if ($key === null) {
+    $fkey = var_export($args, true);
+    $key = $fkey;
+  } else {
+    $fkey = var_export($key, true);
+  }
+  if (is_string($func)) {
+    $fkey = "$func$fkey";
+  } else {
+    $fkey = var_export($func, true) . $fkey;
+  }
+
+  $cachefile = $cachedir . '/' .  sha1($fkey);
+
+  $updating = false;
+  for ($i = 0; $i < 10; $i++) {
+    $fp = @fopen($cachefile, 'r+');
+    if ($fp) {
+      flock($fp, LOCK_SH);
+      /* is it current? */
+      $st = fstat($fp);
+      if ($st['size'] == 0) {
+        /* not valid to have 0 size; we're likely racing with the
+         * creator */
+        flock($fp, LOCK_UN);
+        $fp = null;
+        usleep(100);
+        continue;
+      }
+      if ($st['mtime'] + $cache_life < time()) {
+        /* no longer current; we'll make it current */
+        $updating = true;
+        flock($fp, LOCK_EX);
+        /* we have exclusive access; someone else may have
+         * made it current in the meantime */
+        $st = fstat($fp);
+        if ($st['mtime'] + $cache_life >= time()) {
+          $updating = false;
+        }
+      }
+      break;
+    }
+    /* we're going to create it */
+    $fp = @fopen($cachefile, 'x+');
+    if ($fp) {
+      flock($fp, LOCK_EX);
+      $updating = true;
+      break;
+    }
+  }
+
+  if ($fp) {
+    if ($updating) {
+      ftruncate($fp, 0);
+
+      $result = call_user_func_array($func, $args);
+      $data = new stdclass;
+      $data->key = $key;
+      $data->res = $result;
+      fwrite($fp, serialize($data));
+      flock($fp, LOCK_UN);
+      return $result;
+    }
+
+    $data = unserialize(stream_get_contents($fp));
+    flock($fp, LOCK_UN);
+    return $data->res;
+  }
+  /* if we didn't get a file pointer, just run the command */
+  return call_user_func_array($func, $args);
+}
+
diff --git a/inc/captcha.php b/inc/captcha.php
new file mode 100644 (file)
index 0000000..9d74912
--- /dev/null
@@ -0,0 +1,111 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+interface IMTrackCaptchImplementation {
+  /** return the captcha content */
+  function emit($form);
+  /** check that the captcha is good
+   * Returns true/false */
+  function check($form);
+}
+
+class MTrackCaptcha {
+  static $impl = null;
+
+  static function register(IMTrackCaptchImplementation $impl)
+  {
+    self::$impl = $impl;
+  }
+
+  static function emit($form)
+  {
+    if (self::$impl !== null) {
+      return self::$impl->emit($form);
+    }
+    return '';
+  }
+
+  static function check($form)
+  {
+    if (self::$impl !== null) {
+      return self::$impl->check($form);
+    }
+    return true;
+  }
+}
+
+class MTrackCaptcha_Recaptcha implements IMTrackCaptchImplementation {
+  public $errcode = null;
+  public $pub;
+  public $priv;
+  public $userclass;
+
+  function __construct($pub, $priv, $userclass = 'anonymous|authenticated') {
+    $this->pub = $pub;
+    $this->priv = $priv;
+    $this->userclass = explode("|", $userclass);
+    MTrackCaptcha::register($this);
+  }
+
+  function emit($form)
+  {
+    $class = MTrackAuth::getUserClass();
+    if (!in_array($class, $this->userclass)) {
+      return '';
+    }
+    $pub = $this->pub;
+    $err = $this->errcode === null ? '' : "&error=$this->errcode";
+    return <<<HTML
+<script type='text/javascript' src="https://api-secure.recaptcha.net/challenge?k=$pub$err"></script>
+<noscript>
+  <iframe src="https://api-secure.recaptcha.net/noscript?k=$pub$err"
+    height="300" width="500" frameborder="0"></iframe>
+  <br/>
+  <textarea name="recaptcha_challenge_field" rows="3" cols="40"></textarea>
+  <input type="hidden" name="recaptcha_response_field"
+    value="manual_challenge"/>
+</noscript>
+HTML;
+  }
+
+  function check($form)
+  {
+    $class = MTrackAuth::getUserClass();
+    if (!in_array($class, $this->userclass)) {
+      return true;
+    }
+    if (empty($_POST['recaptcha_challenge_field']) or
+        empty($_POST['recaptcha_response_field'])) {
+      return array('false', 'incorrect-captcha-sol');
+    }
+
+    $data = http_build_query(array(
+          'privatekey' => $this->priv,
+          'remoteip' => $_SERVER['REMOTE_ADDR'],
+          'challenge' => $_POST['recaptcha_challenge_field'],
+          'response' => $_POST['recaptcha_response_field'],
+          ));
+    $params = array(
+        'http' => array(
+          'method' => 'POST',
+          'content' => $data,
+          ),
+        );
+    $ctx = stream_context_create($params);
+
+    /* first line: true/false
+     * second line: error code
+     */
+    $res = array();
+    foreach (file('http://api-verify.recaptcha.net/verify', 0, $ctx) as $line) {
+      $res[] = trim($line);
+    }
+    if ($res[0] == 'true') {
+      return true;
+    }
+    $this->errcode = $res[1];
+    return false;
+  }
+
+}
+
diff --git a/inc/changeset.php b/inc/changeset.php
new file mode 100644 (file)
index 0000000..415ca5d
--- /dev/null
@@ -0,0 +1,110 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackChangeset {
+  public $cid = null;
+  public $who = null;
+  public $object = null;
+  public $reason = null;
+  public $when = null;
+  private $count = 0;
+
+  /* used by the import script to allow batching */
+  static $use_txn = true;
+
+  static function get($cid) {
+    foreach (MTrackDB::q('select * from changes where cid = ?', $cid)
+        ->fetchAll() as $row) {
+      $CS = new MTrackChangeset;
+      $CS->cid = $cid;
+      $CS->who = $row['who'];
+      $CS->object = $row['object'];
+      $CS->reason = $row['reason'];
+      $CS->when = $row['changedate'];
+      return $CS;
+    }
+    throw new Exception("invalid changeset id $cid");
+  }
+
+  static function begin($object, $reason = '', $when = null) {
+    $CS = new MTrackChangeset;
+
+    $db = MTrackDB::get();
+    if (self::$use_txn) {
+      $db->beginTransaction();
+    }
+
+    $CS->who = MTrackAuth::whoami();
+    $CS->object = $object;
+    $CS->reason = $reason;
+
+    if ($when === null) {
+      $CS->when = MTrackDB::unixtime(time());
+      $q = MTrackDB::q(
+        "INSERT INTO changes (who, object, reason, changedate)
+          values (?,?,?,?)",
+        $CS->who, $CS->object, $CS->reason, $CS->when);
+    } else {
+      $CS->when = MTrackDB::unixtime($when);
+      $q = MTrackDB::q(
+        "INSERT INTO changes (who, object, reason, changedate)
+        values (?,?,?,?)",
+        $CS->who, $CS->object, $CS->reason, $CS->when);
+    }
+
+    $CS->cid = MTrackDB::lastInsertId('changes', 'cid');
+
+    return $CS;
+  }
+
+  function commit()
+  {
+    if ($this->count == 0) {
+//      throw new Exception("no changes were made as part of this changeset");
+    }
+    if (self::$use_txn) {
+      $db = MTrackDB::get();
+      $db->commit();
+    }
+  }
+
+  function addentry($fieldname, $action, $old, $value = null)
+  {
+    MTrackDB::q("INSERT INTO change_audit 
+      (cid, fieldname, action, oldvalue, value)
+      VALUES (?, ?, ?, ?, ?)",
+      $this->cid, $fieldname, $action, $old, $value);
+    $this->count++;
+  }
+
+  function add($fieldname, $old, $new)
+  {
+    if ($old == $new) {
+      return;
+    }
+    if (!strlen($old)) {
+      $this->addentry($fieldname, 'set', $old, $new);
+      return;
+    }
+    if (!strlen($new)) {
+      $this->addentry($fieldname, 'deleted', $old, $new);
+      return;
+    }
+    $this->addentry($fieldname, 'changed', $old, $new);
+  }
+
+  function setObject($object)
+  {
+    $this->object = $object;
+    MTrackDB::q('update changes set object = ? where cid = ?',
+      $this->object, $this->cid);
+  }
+
+  function setReason($reason)
+  {
+    $this->reason = $reason;
+    MTrackDB::q('update changes set reason = ? where cid = ?',
+      $this->reason, $this->cid);
+  }
+
+}
diff --git a/inc/commit-hook.php b/inc/commit-hook.php
new file mode 100644 (file)
index 0000000..292696c
--- /dev/null
@@ -0,0 +1,423 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+interface IMTrackCommitHookBridge {
+  function enumChangedOrModifiedFileNames();
+  function getFileStream($filename);
+  function getCommitMessage();
+  /* returns a tracklink describing the change (eg: [123]) */
+  function getChangesetDescriptor();
+}
+
+class MTrackCommitHookChangeEvent {
+  /** Revision or changeset identifier for this particular item,
+   * in wiki syntax */
+  public $rev;
+
+  /** commit message associated with this revision */
+  public $changelog;
+
+  /** who committed this revision */
+  public $changeby;
+
+  /** when this revision was committed */
+  public $ctime;
+
+  /** a hash value that will be consistent when being merged from multiple
+   * repos */
+  public $hash;
+}
+
+interface IMTrackCommitHookBridge2 extends IMTrackCommitHookBridge {
+  /* returns an array; each element is an MTrackCommitHookChangeEvent */
+  function getChanges();
+}
+
+/* The listener protocol is to return true if all is good,
+ * or to return either a string or an array of strings that
+ * detail why a change is not allowed to proceed */
+interface IMTrackCommitListener {
+  function vetoCommit($msg, $files, $actions);
+  function postCommit($msg, $files, $actions);
+}
+
+class MTrackCommitCheck_NoEmptyLogMessage implements IMTrackCommitListener {
+  function __construct() {
+    MTrackCommitChecker::registerListener($this);
+  }
+
+  function vetoCommit($msg, $files, $actions) {
+    if (!strlen(trim($msg))) {
+      return "Empty log messages are not allowed.\n";
+    }
+    return true;
+  }
+
+  function postCommit($msg, $files, $actions) {
+    return true;
+  }
+}
+
+class MTrackCommitCheck_RequiresTimeReference implements IMTrackCommitListener {
+  function __construct() {
+    MTrackCommitChecker::registerListener($this);
+  }
+
+  function vetoCommit($msg, $files, $actions) {
+    $spent = false;
+    foreach ($actions as $act) {
+      if (isset($act[2])) {
+        return true;
+      }
+    }
+    return "You must include at least one ticket and time reference in your\n".
+      "commit message, using the \"refs #123 (spent 2.5)\" notation.\n"
+      ;
+  }
+
+  function postCommit($msg, $files, $actions) {
+    return true;
+  }
+}
+
+class MTrackCommitChecker {
+  static $fileChecks = array(
+    'php' => 'checkPHP',
+  );
+  static $listeners = array();
+  var $repo;
+
+  static function registerListener(IMTrackCommitListener $l)
+  {
+    self::$listeners[] = $l;
+  }
+
+  function checkVeto()
+  {
+    $args = func_get_args();
+    $method = array_shift($args);
+    $reasons = array();
+
+    foreach (self::$listeners as $l) {
+      $v = call_user_func_array(array($l, $method), $args);
+      if ($v !== true) {
+        if ($v === null || $v === false) {
+          $reasons[] = sprintf("%s:%s() returned %s",
+            get_class($l), $method, $v === null ? 'null' : 'false');
+        } elseif (is_array($v)) {
+          foreach ($v as $m) {
+            $reasons[] = $m;
+          }
+        } else {
+          $reasons[] = $v;
+        }
+      }
+    }
+    if (count($reasons)) {
+      throw new MTrackVetoException($reasons);
+    }
+  }
+
+  function __construct($repo) {
+    $this->repo = $repo;
+  }
+
+  function parseCommitMessage($msg) {
+    // Parse the commit message and look for commands;
+    // returns each recognized command and its args in an array
+
+    $close = array('resolves', 'resolved', 'close', 'closed',
+                   'closes', 'fix', 'fixed', 'fixes');
+    $refs = array('addresses', 'references', 'referenced',
+                  'refs', 'ref', 'see', 're');
+
+    $cmds = join('|', $close) . '|' . join('|', $refs);
+    $timepat = '(?:\s*\((?:spent|sp)\s*(-?[0-9]*(?:\.[0-9]+)?)\s*(?:hours?|hrs)?\s*\))?';
+    $tktref = "(?:#|(?:(?:ticket|issue|bug):?\s*))([a-z]*[0-9]+)$timepat";
+
+    $pat = "(?P<action>(?:$cmds))\s*(?P<ticket>$tktref(?:(?:[, &]*|\s+and\s+)$tktref)*)";
+
+    $M = array();
+    $actions = array();
+
+    if (preg_match_all("/$pat/smi", $msg, $M, PREG_SET_ORDER)) {
+      foreach ($M as $match) {
+        if (in_array($match['action'], $close)) {
+          $action = 'close';
+        } else {
+          $action = 'ref';
+        }
+        $tickets = array();
+        $T = array();
+        if (preg_match_all("/$tktref/smi", $match['ticket'],
+            $T, PREG_SET_ORDER)) {
+
+          foreach ($T as $tmatch) {
+            if (isset($tmatch[2])) {
+              // [ action, ticket, spent ]
+              $actions[] = array($action, $tmatch[1], $tmatch[2]);
+            } else {
+              // [ action, ticket ]
+              $actions[] = array($action, $tmatch[1]);
+            }
+          }
+        }
+      }
+    }
+    return $actions;
+  }
+
+  function preCommit(IMTrackCommitHookBridge $bridge) {
+    MTrackACL::requireAllRights("repo:" . $this->repo->repoid, 'commit');
+    $files = $bridge->enumChangedOrModifiedFileNames();
+    $fqfiles = array();
+    foreach ($files as $filename) {
+      $fqfiles[] = $this->repo->shortname . '/' . $filename;
+      $pi = pathinfo($filename);
+      if (isset(self::$fileChecks[$pi['extension']])) {
+        $lint = self::$fileChecks[$pi['extension']];
+        $fp = $bridge->getFileStream($filename);
+        $this->$lint($filename, $fp);
+        $fp = null;
+      }
+    }
+    $changes = $this->_getChanges($bridge);
+    foreach ($changes as $c) {
+      $log = $c->changelog;
+      $actions = $this->parseCommitMessage($log);
+
+      // check permissions on the tickets
+      $tickets = array();
+      foreach ($actions as $act) {
+        $tkt = $act[1];
+        $tickets[$tkt] = $tkt;
+      }
+      $reasons = array();
+      foreach ($tickets as $tkt) {
+        if (strlen($tkt) == 32) {
+          $T = MTrackIssue::loadById($tkt);
+        } else {
+          $T = MTrackIssue::loadByNSIdent($tkt);
+        }
+
+        if ($T === null) {
+          $reasons[] = "#$tkt is not a valid ticket\n";
+          continue;
+        }
+
+        $accounted = false;
+        if ($c->hash !== null) {
+          list($accounted) = MTrackDB::q(
+              'select count(hash) from ticket_changeset_hashes
+              where tid = ? and hash = ?',
+            $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0);
+          if ($accounted) {
+            continue;
+          }
+        }
+
+        if (!MTrackACL::hasAllRights("ticket:$T->tid", "modify")) {
+          $reasons[] = MTrackAuth::whoami() . " does not have permission to modify #$tkt\n";
+        } else if (!$T->isOpen()) {
+          $reasons[] = " ** #$tkt is already closed.\n ** You must either re-open it (if it has not already shipped)\n ** or open a new ticket to track this issue\n";
+        }
+      }
+    }
+    if (count($reasons) > 0) {
+      throw new MTrackVetoException($reasons);
+    }
+    $this->checkVeto('vetoCommit', $log, $files, $actions);
+  }
+
+  private function _getChanges(IMTrackCommitHookBridge $bridge)
+  {
+    $changes = array();
+    if ($bridge instanceof IMTrackCommitHookBridge2) {
+      $changes = $bridge->getChanges();
+    } else {
+      $c = new MTrackCommitHookChangeEvent;
+      $c->rev = $bridge->getChangesetDescriptor();
+      $c->changelog = $bridge->getCommitMessage();
+      $c->changeby = MTrackAuth::whoami();
+      $c->ctime = time();
+      $changes[] = $c;
+    }
+    return $changes;
+  }
+
+  function postCommit(IMTrackCommitHookBridge $bridge)
+  {
+    $files = $bridge->enumChangedOrModifiedFileNames();
+    $fqfiles = array();
+    foreach ($files as $filename) {
+      $fqfiles[] = $this->repo->shortname . '/' . $filename;
+    }
+
+    // build up overall picture of what needs to be applied to tickets
+    $changes = $this->_getChanges($bridge);
+
+    // Deferred by tid
+    $deferred = array();
+    $T_by_tid = array();
+    $hashed = array();
+
+    // For correct attribution of spent time
+    $spent_by_tid_by_user = array();
+
+    // Changes that didn't ref a ticket; we want to show something
+    // on the timeline
+    $no_ticket = array();
+
+    $me = mtrack_canon_username(MTrackAuth::whoami());
+
+    foreach ($changes as $c) {
+      $tickets = array();
+      $log = $c->changelog;
+
+      $actions = $this->parseCommitMessage($log);
+      foreach ($actions as $act) {
+        $what = $act[0];
+        $tkt = $act[1];
+        $tickets[$tkt][$what] = $what;
+        if (isset($act[2])) {
+          $tickets[$tkt]['spent'] += $act[2];
+        }
+      }
+      if (count($tickets) == 0) {
+        $no_ticket[] = $c;
+        continue;
+      }
+      // apply changes to tickets
+      foreach ($tickets as $tkt => $act) {
+        if (strlen($tkt) == 32 && isset($T_by_tid[$tkt])) {
+          $T = $T_by_tid[$tkt];
+        } else {
+          if (strlen($tkt) == 32) {
+            $T = MTrackIssue::loadById($tkt);
+          } else {
+            $T = MTrackIssue::loadByNSIdent($tkt);
+          }
+          $T_by_tid[$T->tid] = $T;
+        }
+
+        $accounted = false;
+        if ($c->hash !== null) {
+          if (isset($hashed[$T->tid][$c->hash])) {
+            $accounted = true;
+          } else {
+            list($accounted) = MTrackDB::q(
+              'select count(hash) from ticket_changeset_hashes
+              where tid = ? and hash = ?',
+              $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0);
+            if (!$accounted) {
+              $hashed[$T->tid][$c->hash] = $c->hash;
+            }
+          }
+        }
+
+        if ($accounted) {
+          $deferred[$T->tid]['comments'][] =
+            "(In $c->rev) merged to [repo:" . 
+              $this->repo->getBrowseRootName() . "]";
+          continue;
+        }
+        $log = "(In " . $c->rev . ") ";
+        if ($c->changeby != $me) {
+          $log .= " (on behalf of [user:$c->changeby]) ";
+        }
+        $log .= $c->changelog;
+        $deferred[$T->tid]['comments'][] = $log;
+        if (isset($act['spent']) && $c->changeby != $me) {
+          $spent_by_tid_by_user[$T->tid][$c->changeby][] = $act['spent'];
+          unset($act['spent']);
+        }
+        $deferred[$T->tid]['act'][] = $act;
+
+      }
+      $this->checkVeto('postCommit', $log, $fqfiles, $actions);
+    }
+
+    foreach ($deferred as $tid => $info) {
+      $T = $T_by_tid[$tid];
+
+      $log = join("\n\n", $info['comments']);
+
+      $CS = MTrackChangeset::begin("ticket:" . $T->tid, $log);
+
+      if (isset($hashed[$T->tid])) {
+        foreach ($hashed[$T->tid] as $hash) {
+          MTrackDB::q(
+            'insert into ticket_changeset_hashes(tid, hash) values (?, ?)',
+            $T->tid, $hash);
+        }
+      }
+
+      $T->addComment($log);
+      if (isset($info['act'])) foreach ($info['act'] as $act) {
+        if (isset($act['close'])) {
+          $T->resolution = 'fixed';
+          $T->close();
+        }
+        if (isset($act['spent'])) {
+          $T->addEffort($act['spent']);
+        }
+      }
+      $T->save($CS);
+      $CS->commit();
+    }
+    foreach ($spent_by_tid_by_user as $tid => $sdata) {
+      // Load it fresh here, as there seems to be an issue with saving
+      // a second set of changes on a pre-existing object
+      $T = MTrackIssue::loadById($tid);
+      foreach ($sdata as $user => $time) {
+        MTrackAuth::su($user);
+        $CS = MTrackChangeset::begin("ticket:" . $T->tid,
+          "Tracking time from prior push");
+        MTrackAuth::drop();
+        foreach ($time as $spent) {
+          $T->addEffort($spent);
+        }
+        $T->save($CS);
+        $CS->commit();
+      }
+    }
+    $log = '';
+    foreach ($no_ticket as $c) {
+      $log .= "(In " . $c->rev . ") ";
+      if ($c->changeby != $me) {
+        $log .= " (on behalf of [user:$c->changeby]) ";
+      }
+      $log .= $c->changelog . "\n\n";
+    }
+    $CS = MTrackChangeset::begin("repo:" . $this->repo->repoid, $log);
+    $CS->commit();
+  }
+
+  function checkPHP($filename, $fp) {
+    $pipes = null;
+    $proc = proc_open(MTrackConfig::get('tools', 'php') . " -l", array(
+        0 => array('pipe', 'r'),
+        1 => array('pipe', 'w'),
+        2 => array('pipe', 'w')
+      ), $pipes);
+
+    // send in data
+    stream_copy_to_stream($fp, $pipes[0]);
+    $fp = null;
+    $pipes[0] = null;
+
+    $output = stream_get_contents($pipes[1]);
+    $output .= stream_get_contents($pipes[2]);
+    $st = proc_get_status($proc);
+    if ($st['running']) {
+      proc_terminate($proc);
+      sleep(1);
+      $st = proc_get_status($proc);
+    }
+    if ($st['exitcode'] != 0) {
+      throw new Exception("$filename: $output");
+    }
+    return true;
+  }
+}
+
diff --git a/inc/common.php b/inc/common.php
new file mode 100644 (file)
index 0000000..4698d0d
--- /dev/null
@@ -0,0 +1,66 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+define('MTRACK_INC_DIR', dirname(__FILE__));
+
+set_include_path(
+  MTRACK_INC_DIR . DIRECTORY_SEPARATOR . 'lib' .
+  PATH_SEPARATOR .
+  get_include_path()
+  );
+
+include MTRACK_INC_DIR . '/configuration.php';
+include MTRACK_INC_DIR . '/watch.php';
+include MTRACK_INC_DIR . '/cache.php';
+include MTRACK_INC_DIR . '/UUID.php';
+include MTRACK_INC_DIR . '/attachment.php';
+include MTRACK_INC_DIR . '/database.php';
+include MTRACK_INC_DIR . '/search.php';
+include MTRACK_INC_DIR . '/keywords.php';
+include MTRACK_INC_DIR . '/wiki.php';
+include MTRACK_INC_DIR . '/changeset.php';
+include MTRACK_INC_DIR . '/commit-hook.php';
+include MTRACK_INC_DIR . '/captcha.php';
+include MTRACK_INC_DIR . '/web.php';
+include MTRACK_INC_DIR . '/auth.php';
+include MTRACK_INC_DIR . '/acl.php';
+include MTRACK_INC_DIR . '/issue.php';
+include MTRACK_INC_DIR . '/report.php';
+include MTRACK_INC_DIR . '/milestone.php';
+include MTRACK_INC_DIR . '/wiki-item.php';
+include MTRACK_INC_DIR . '/scm.php';
+include MTRACK_INC_DIR . '/scm/hg.php';
+include MTRACK_INC_DIR . '/scm/git.php';
+include MTRACK_INC_DIR . '/scm/svn.php';
+include MTRACK_INC_DIR . '/timeline.php';
+include MTRACK_INC_DIR . '/customfield.php';
+include MTRACK_INC_DIR . '/syntax.php';
+include MTRACK_INC_DIR . '/snippet.php';
+
+MTrackConfig::boot();
+
+if (php_sapi_name() != 'cli') {
+$timezone = null;
+if (MTrackAuth::whoami() != 'anonymous') {
+  foreach (MTrackDB::q('select timezone from userinfo where userid = ?',
+      MTrackAuth::whoami())->fetchAll() as $row) {
+    $timezone = $row[0];
+  }
+}
+if (empty($timezone)) {
+  $timezone = MTrackConfig::get('core', 'timezone');
+}
+if (!empty($timezone)) {
+  $timezone_crutch = array(
+    'PST' => 'America/Los_Angeles',
+    'PDT' => 'America/Los_Angeles',
+    'EDT' => 'America/New_York',
+    'EST' => 'America/New_York',
+  );
+  if (isset($timezone_crutch[$timezone])) {
+    $timezone = $timezone_crutch[$timezone];
+  }
+  date_default_timezone_set($timezone);
+}
+}
+
diff --git a/inc/configuration.php b/inc/configuration.php
new file mode 100644 (file)
index 0000000..9289e50
--- /dev/null
@@ -0,0 +1,152 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackConfig {
+  static $ini = null;
+  static $runtime = array();
+
+  static function getLocation() {
+    $location = getenv('MTRACK_CONFIG_FILE');
+    if (!strlen($location)) {
+      $location = dirname(__FILE__) . '/../config.ini';
+    }
+    return $location;
+  }
+
+  static function parseIni() {
+    if (self::$ini !== null) {
+      return self::$ini;
+    }
+    $location = self::getLocation();
+    self::$ini = @parse_ini_file($location, true);
+    if (self::$ini === false) {
+      self::$ini = array();
+    }
+
+    /* locate the runtime editable config data */
+    $filename = self::_get('core', 'runtime.config');
+    if (!$filename) {
+      $filename = self::_get('core', 'vardir') . '/runtime.config';
+    }
+    if (file_exists($filename)) {
+      $fp = fopen($filename, 'r');
+      flock($fp, LOCK_SH);
+      self::$runtime = @parse_ini_file($filename, true);
+      if (self::$runtime === false) {
+        self::$runtime = array();
+      }
+      flock($fp, LOCK_UN);
+      $fp = null;
+    }
+  }
+
+  static function set($section, $option, $value) {
+    self::$runtime[$section][$option] = $value;
+  }
+
+  static function remove($section, $option) {
+    unset(self::$runtime[$section][$option]);
+  }
+
+  static function save() {
+    $filename = self::_get('core', 'runtime.config');
+    if (!$filename) {
+      $filename = self::_get('core', 'vardir') . '/runtime.config';
+    }
+    if (file_exists($filename)) {
+      $fp = fopen($filename, 'r+');
+    } else {
+      $fp = fopen($filename, 'w');
+    }
+    flock($fp, LOCK_EX);
+    ftruncate($fp, 0);
+    foreach (self::$runtime as $section => $opts) {
+      fwrite($fp, "[$section]\n");
+      foreach ($opts as $k => $v) {
+        $v = addcslashes($v, "\"\r\n\t");
+        fwrite($fp, "$k = \"$v\"\n");
+      }
+      fwrite($fp, "\n");
+    }
+    flock($fp, LOCK_UN);
+    $fp = null;
+  }
+
+  static function get($section, $option) {
+    self::parseIni();
+    return self::_get($section, $option);
+  }
+
+  static function _get($section, $option) {
+    $ini = self::$ini;
+    if (isset(self::$ini[$section][$option])) {
+      $val = self::$ini[$section][$option];
+    } else if (isset(self::$runtime[$section][$option])) {
+      $val = self::$runtime[$section][$option];
+    } else {
+      return null;
+    }
+
+    while (preg_match('/@\{([a-zA-Z0-9_]+):([a-zA-Z0-9_]+)\}/', $val, $M)) {
+      $rep = self::_get($M[1], $M[2]);
+      $val = str_replace($M[0], $rep, $val);
+    }
+
+    return $val;
+  }
+
+  static function getSection($section) {
+    self::parseIni();
+    if (isset(self::$ini[$section])) {
+      $S = self::$ini[$section];
+    } else {
+      $S = null;
+    }
+    if (isset(self::$runtime[$section])) {
+      $R = self::$runtime[$section];
+    } else {
+      $R = null;
+    }
+    if ($S && $R) {
+      return array_merge($S, $R);
+    }
+    if ($S) {
+      return $S;
+    }
+    if ($R) {
+      return $R;
+    }
+    return array();
+  }
+
+  static function append($section, $option, $value) {
+    if (self::$ini[$section][$option] != $value) {
+      $location = self::getLocation();
+      $data = file_get_contents($location);
+      $data .= "\n[$section]\n$option = $value\n";
+      file_put_contents($location, $data);
+      self::$ini[$section][$option] = $value;
+    }
+  }
+
+  /* loads plugins */
+  static function boot() {
+    if (isset($_GLOBALS['MTRACK_CONFIG_SKIP_BOOT'])) {
+      return;
+    }
+    $inc = self::get('core', 'includes');
+    if ($inc !== null) {
+      foreach (preg_split("/\s*,\s*/", $inc) as $filename) {
+        require_once $filename;
+      }
+    }
+    $plugins = self::getSection('plugins');
+    if (is_array($plugins)) foreach ($plugins as $classpat => $paramline) {
+      $params = preg_split("/\s*,\s*/", $paramline);
+
+      $rcls = new ReflectionClass($classpat);
+      $obj = $rcls->newInstanceArgs($params);
+    }
+  }
+}
+
diff --git a/inc/customfield.php b/inc/customfield.php
new file mode 100644 (file)
index 0000000..ce731b8
--- /dev/null
@@ -0,0 +1,252 @@
+<?php # vim:ts=2:sw=2:et:
+
+class MTrackTicket_CustomField {
+  var $name;
+  var $type;
+  var $label;
+  var $group;
+  var $order = 0;
+  var $default;
+  var $options;
+
+  static function canonName($name) {
+    if (!preg_match("/^x_/", $name)) {
+      $name = "x_$name";
+    }
+    return $name;
+  }
+
+  /** load the field definition from the configuration file */
+  static function load($name) {
+    if (!preg_match("/^x_[a-z_]+$/", $name)) {
+      throw new Exception("invalid field name $name");
+    }
+
+    $field = new MTrackTicket_CustomField;
+    $field->name = $name;
+
+    $field->type  = MTrackConfig::get('ticket.custom', "$name.type");
+    $field->label = MTrackConfig::get('ticket.custom', "$name.label");
+    $field->group = MTrackConfig::get('ticket.custom', "$name.group");
+    $field->order   = (int)MTrackConfig::get('ticket.custom', "$name.order");
+    $field->default = MTrackConfig::get('ticket.custom', "$name.default");
+    $field->options = MTrackConfig::get('ticket.custom', "$name.options");
+
+    return $field;
+  }
+
+  function save() {
+    if (!preg_match("/^x_[a-z_]+$/", $this->name)) {
+      throw new Exception("invalid field name $this->name");
+    }
+    $name = $this->name;
+    MTrackConfig::set('ticket.custom', "$name.type", $this->type);
+    MTrackConfig::set('ticket.custom', "$name.label", $this->label);
+    MTrackConfig::set('ticket.custom', "$name.group", $this->group);
+    MTrackConfig::set('ticket.custom', "$name.order", (int)$this->order);
+    MTrackConfig::set('ticket.custom', "$name.default", $this->default);
+    MTrackConfig::set('ticket.custom', "$name.options", $this->options);
+  }
+
+  function ticketData() {
+    /* compatible with the $FIELDSET data used in web/ticket.php */
+    $data = array(
+      'label' => $this->label,
+      'type' => $this->type,
+    );
+
+    if (strlen($this->default)) {
+      $data['default'] = $this->default;
+    }
+
+    switch ($this->type) {
+      case 'multi':
+      case 'wiki':
+      case 'shortwiki':
+        $data['ownrow'] = true;
+        $data['rows'] = 5;
+        $data['cols'] = 78;
+        break;
+      case 'select':
+      case 'multiselect':
+        $options = array('' => ' --- ');
+        foreach (explode('|', $this->options) as $opt) {
+          $options[$opt] = $opt;
+        }
+        $data['options'] = $options;
+        break;
+    }
+    return $data;
+  }
+}
+
+class MTrackTicket_CustomFields
+  implements IMTrackIssueListener
+{
+  var $fields = array();
+
+  var $field_types = array(
+    'text' => 'Text (single line)',
+    'multi' => 'Text (multi-line)',
+    'wiki' => 'Wiki',
+    'shortwiki' => 'Wiki (shorter height)',
+    'select' => 'Select box (choice of one)',
+// Don't allow multi-select for now; need a sane way to make the value
+// into an array.
+//    'multiselect' => 'Multiple select',
+  );
+
+  function save() {
+    $this->alterSchema();
+
+    $fieldlist = join(',', array_keys($this->fields));
+    MTrackConfig::set('ticket', 'customfields', $fieldlist);
+
+    foreach ($this->fields as $field) {
+      $field->save();
+    }
+  }
+
+  function fieldByName($name, $create = false) {
+    $name = MTrackTicket_CustomField::canonName($name);
+    if (!isset($this->fields[$name]) && $create) {
+      $field = new MTrackTicket_CustomField;
+      $field->name = $name;
+      $this->fields[$name] = $field;
+    } else if (!isset($this->fields[$name])) {
+      return null;
+    }
+    return $this->fields[$name];
+  }
+
+  function deleteField($field) {
+    if (!($field instanceof MTrackTicket_CustomField)) {
+      $field = $this->fieldByName($field);
+    }
+    if (!($field instanceof MTrackTicket_CustomField)) {
+      throw new Exception("can't delete an unknown field");
+    }
+    unset($this->fields[$field->name]);
+  }
+
+  function vetoMilestone(MTrackIssue $issue,
+      MTrackMilestone $ms, $assoc = true) {
+    return true;
+  }
+  function vetoKeyword(MTrackIssue $issue,
+      MTrackKeyword $kw, $assoc = true) {
+    return true;
+  }
+  function vetoComponent(MTrackIssue $issue,
+      MTrackComponent $comp, $assoc = true) {
+    return true;
+  }
+  function vetoProject(MTrackIssue $issue,
+      MTrackProject $proj, $assoc = true) {
+    return true;
+  }
+  function vetoComment(MTrackIssue $issue, $comment) {
+    return true;
+  }
+  function vetoSave(MTrackIssue $issue, $oldFields) {
+    return true;
+  }
+
+  function _orderField($a, $b) {
+    $diff = $a->order - $b->order;
+    if ($diff == 0) {
+      return strnatcasecmp($a->label, $b->label);
+    }
+    return $diff;
+  }
+
+  function getGroupedFields() {
+    $grouped = array();
+    foreach ($this->fields as $field) {
+      $grouped[$field->group][$field->name] = $field;
+    }
+    $result = array();
+    $names = array_keys($grouped);
+    asort($grouped);
+    foreach ($grouped as $name => $group) {
+      uasort($group, array($this, '_orderField'));
+      $result[$name] = $group;
+    }
+    return $result;
+  }
+
+  function augmentFormFields(MTrackIssue $issue, &$fieldset) {
+    $grouped = $this->getGroupedFields();
+    foreach ($grouped as $group) {
+      foreach ($group as $field) {
+        $fieldset[$field->group][$field->name] = $field->ticketData();
+      }
+    }
+  }
+
+  function augmentSaveParams(MTrackIssue $issue, &$params) {
+    foreach ($this->fields as $field) {
+      $params[$field->name] = $issue->{$field->name};
+    }
+  }
+  function augmentIndexerFields(MTrackIssue $issue, &$idx) {
+    foreach ($this->fields as $field) {
+      $idx[$field->name] = $issue->{$field->name};
+    }
+  }
+
+  function applyPOSTData(MTrackIssue $issue, $post) {
+    foreach ($this->fields as $field) {
+      if ($field->type == 'multiselect') {
+        $issue->{$field->name} = join('|', $post[$field->name]);
+      } else {
+        $issue->{$field->name} = $post[$field->name];
+      }
+    }
+  }
+
+  function alterSchema() {
+    $names = array();
+    foreach ($this->fields as $field) {
+      $names[] = $field->name;
+    }
+    $db = MTrackDB::get();
+    try {
+      $db->exec("select " . join(', ', $names) . ' from tickets limit 1');
+    } catch (Exception $e) {
+      foreach ($names as $name) {
+        try {
+          $db->exec("ALTER TABLE tickets add column $name text");
+        } catch (Exception $e) {
+        }
+      }
+    }
+  }
+
+  function __construct() {
+    MTrackIssue::registerListener($this);
+
+    /* read in custom fields from ini */
+    $fieldlist = MTrackConfig::get('ticket', 'customfields');
+    if ($fieldlist) {
+      $fieldlist = preg_split("/\s*,\s*/", $fieldlist);
+      foreach ($fieldlist as $fieldname) {
+        $field = MTrackTicket_CustomField::load($fieldname);
+        $this->fields[$field->name] = $field;
+      }
+    }
+  }
+
+  static $me = null;
+  static function getInstance() {
+    if (self::$me !== null) {
+      return self::$me;
+    }
+    self::$me = new MTrackTicket_CustomFields;
+    return self::$me;
+  }
+}
+
+MTrackTicket_CustomFields::getInstance();
+
+
diff --git a/inc/database.php b/inc/database.php
new file mode 100644 (file)
index 0000000..0bdeaca
--- /dev/null
@@ -0,0 +1,500 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+interface IMTrackDBExtension {
+  /** allows the extension an opportunity to adjust the environment;
+   * register sqlite functions or otherwise tweak parameters */
+  function onHandleCreated(PDO $db);
+}
+
+class MTrackDBSchema_Table {
+  var $name;
+  var $fields;
+  var $keys;
+  var $triggers;
+
+  /* compares two tables; returns true if they are identical,
+   * false if the definitions are altered */
+  function sameAs(MTrackDBSchema_Table $other) {
+    if ($this->name != $other->name) {
+      throw new Exception("can only compare tables with the same name!");
+    }
+    foreach (array('fields', 'keys', 'triggers') as $propname) {
+      if (!is_array($this->{$propname})) continue;
+      foreach ($this->{$propname} as $f) {
+        if (!isset($other->{$propname}[$f->name])) {
+#          echo "$propname $f->name is new\n";
+          return false;
+        }
+        $o = clone $other->{$propname}[$f->name];
+        $f = clone $f;
+        unset($o->comment);
+        unset($f->comment);
+        if ($f != $o) {
+#          echo "$propname $f->name are not equal\n";
+#          var_dump($f);
+#          var_dump($o);
+          return false;
+        }
+      }
+      if (!is_array($other->{$propname})) continue;
+      foreach ($other->{$propname} as $f) {
+        if (!isset($this->{$propname}[$f->name])) {
+#          echo "$propname $f->name was deleted\n";
+          return false;
+        }
+      }
+    }
+
+    return true;
+  }
+}
+
+interface IMTrackDBSchema_Driver {
+  function setDB(PDO $db);
+  function determineVersion();
+  function createTable(MTrackDBSchema_Table $table);
+  function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to);
+  function dropTable(MTrackDBSchema_Table $table);
+};
+
+class MTrackDBSchema_Generic implements IMTrackDBSchema_Driver {
+  var $db;
+  var $typemap = array();
+
+  function setDB(PDO $db) {
+    $this->db = $db;
+  }
+
+  function determineVersion() {
+    try {
+      $q = $this->db->query('select version from mtrack_schema');
+      if ($q) {
+        foreach ($q as $row) {
+          return $row[0];
+        }
+      }
+    } catch (Exception $e) {
+    }
+    return null;
+  }
+
+  function computeFieldCreate($f) {
+    $str = "\t$f->name ";
+    $str .= isset($this->typemap[$f->type]) ? $this->typemap[$f->type] : $f->type;
+    if (isset($f->nullable) && $f->nullable == '0') {
+      $str .= ' NOT NULL ';
+    }
+    if (isset($f->default)) {
+      if (!strlen($f->default)) {
+        $str .= " DEFAULT ''";
+      } else {
+        $str .= " DEFAULT $f->default";
+      }
+    }
+    return $str;
+  }
+
+  function computeIndexCreate($table, $k) {
+    switch ($k->type) {
+      case 'unique':
+        $kt = ' UNIQUE ';
+        break;
+      case 'multiple':
+      default:
+        $kt = '';
+    }
+    return "CREATE $kt INDEX $k->name on $table->name (" . join(', ', $k->fields) . ")";
+  }
+
+  function createTable(MTrackDBSchema_Table $table)
+  {
+    echo "Create $table->name\n";
+
+    $pri_key = null;
+
+    $sql = array();
+    foreach ($table->fields as $f) {
+      if ($f->type == 'autoinc') {
+        $pri_key = $f->name;
+      }
+      $str = $this->computeFieldCreate($f);
+      $sql[] = $str;
+    }
+
+    if (is_array($table->keys)) foreach ($table->keys as $k) {
+      if ($k->type != 'primary') continue;
+      if ($pri_key !== null) continue;
+      $sql[] = "\tprimary key (" . join(', ', $k->fields) . ")";
+    }
+
+    $sql = "CREATE TABLE $table->name (\n" .
+      join(",\n", $sql) .
+      ")\n";
+
+#    echo $sql;
+
+    $this->db->exec($sql);
+
+    if (is_array($table->keys)) foreach ($table->keys as $k) {
+      if ($k->type == 'primary') continue;
+      $this->db->exec($this->computeIndexCreate($table, $k));
+    }
+  }
+
+  function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to)
+  {
+    /* if keys have changed, we drop the old key definitions before changing the columns */
+
+    echo "Need to alter $from->name\n";
+    throw new Exception("bang!");
+  }
+
+  function dropTable(MTrackDBSchema_Table $table)
+  {
+    echo "Drop $table->name\n";
+    $this->db->exec("drop table $table->name");
+  }
+}
+
+class MTrackDBSchema_SQLite extends MTrackDBSchema_Generic {
+
+  function determineVersion() {
+    /* older versions did not have a schema version table, so we dance
+     * around a little bit, but only for sqlite, as those older versions
+     * didn't support other databases */
+    try {
+      $q = $this->db->query('select version from mtrack_schema');
+      if ($q) {
+        foreach ($q as $row) {
+          return $row[0];
+        }
+      }
+    } catch (Exception $e) {
+    }
+
+    /* do we have any tables at all? if we do, we treat that as schema
+     * version 0 */
+    foreach ($this->db->query('select count(*) from sqlite_master') as $row) {
+      if ($row[0] > 0) {
+        $this->db->exec(
+          'create table mtrack_schema (version integer not null)');
+        return 0;
+      }
+    }
+    return null;
+  }
+
+  var $typemap = array(
+    'autoinc' => 'INTEGER PRIMARY KEY AUTOINCREMENT',
+  );
+
+  function createTable(MTrackDBSchema_Table $table)
+  {
+    parent::createTable($table);
+  }
+
+  function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to)
+  {
+    $tname = $from->name . '_' . uniqid();
+
+    $sql = array();
+    foreach ($to->fields as $f) {
+      if ($f->type == 'autoinc') {
+        $pri_key = $f->name;
+      }
+      $str = $this->computeFieldCreate($f);
+      $sql[] = $str;
+    }
+
+    $sql = "CREATE TEMPORARY TABLE $tname (\n" .
+      join(",\n", $sql) .
+      ")\n";
+
+    $this->db->exec($sql);
+
+    /* copy old data into this table */
+    $sql = "INSERT INTO $tname (";
+    $names = array();
+    foreach ($from->fields as $f) {
+      if (!isset($to->fields[$f->name])) continue;
+      $names[] = $f->name;
+    }
+    $sql .= join(', ', $names);
+    $sql .= ") SELECT " . join(', ', $names) . " from $from->name";
+
+    #echo "$sql\n";
+    $this->db->exec($sql);
+
+    $this->db->exec("DROP TABLE $from->name");
+    $this->createTable($to);
+    $sql = "INSERT INTO $from->name (";
+    $names = array();
+    foreach ($from->fields as $f) {
+      if (!isset($to->fields[$f->name])) continue;
+      $names[] = $f->name;
+    }
+    $sql .= join(', ', $names);
+    $sql .= ") SELECT " . join(', ', $names) . " from $tname";
+    #echo "$sql\n";
+    $this->db->exec($sql);
+    $this->db->exec("DROP TABLE $tname");
+  }
+
+
+}
+
+class MTrackDBSchema_pgsql extends MTrackDBSchema_Generic {
+  var $typemap = array(
+    'autoinc' => 'SERIAL UNIQUE',
+    'timestamp' => 'timestamp with time zone',
+    'blob' => 'bytea',
+  );
+
+  function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to)
+  {
+    $sql = array();
+    $actions = array();
+
+    /* if keys have changed, we drop the old key definitions before changing the columns */
+    if (is_array($from->keys)) foreach ($from->keys as $k) {
+      if (!isset($to->keys[$k->name]) || $to->keys[$k->name] != $k) {
+        if ($k->type == 'primary') {
+          $actions[] = "DROP CONSTRAINT {$from->name}_pkey";
+        } else {
+          $sql[] = "DROP INDEX $k->name";
+        }
+      }
+    }
+
+    foreach ($from->fields as $f) {
+      if (!isset($to->fields[$f->name])) {
+        $actions[] = "DROP COLUMN $f->name";
+        continue;
+      }
+    }
+    foreach ($to->fields as $f) {
+      if (isset($from->fields[$f->name])) continue;
+      $actions[] = "ADD COLUMN " . $this->computeFieldCreate($f);
+    }
+
+    /* changed and new keys */
+    if (is_array($from->keys)) foreach ($from->keys as $k) {
+      if (isset($to->keys[$k->name]) && $to->keys[$k->name] != $k) {
+        if ($k->type == 'primary') {
+          $actions[] = "ADD primary key (" . join(', ', $k->fields) . ")";
+        } else {
+          $sql[] = $this->computeIndexCreate($to, $k);
+        }
+      }
+    }
+    if (is_array($to->keys)) foreach ($to->keys as $k) {
+      if (isset($from->keys[$k->name])) continue;
+      if ($k->type == 'primary') {
+        $actions[] = "ADD primary key (" . join(', ', $k->fields) . ")";
+      } else {
+        $sql[] = $this->computeIndexCreate($to, $k);
+      }
+    }
+
+    if (count($actions)) {
+      $sql[] = "ALTER TABLE $from->name " . join(",\n", $actions);
+    }
+    echo "Need to alter $from->name\n";
+    echo "SQL:\n";
+    var_dump($sql);
+    foreach ($sql as $s) {
+      $this->db->exec($s);
+    }
+  }
+}
+
+class MTrackDBSchema {
+  var $tables;
+  var $version;
+  var $post;
+
+  function __construct($filename) {
+    $s = simplexml_load_file($filename);
+
+    $this->version = (int)$s['version'];
+
+    /* fabricate a table to hold the schema info */
+    $table = new MTrackDBSchema_Table;
+    $table->name = 'mtrack_schema';
+    $f = new stdclass;
+    $f->name = 'version';
+    $f->type = 'integer';
+    $f->nullable = '0';
+    $table->fields[$f->name] = $f;
+    $this->tables[$table->name] = $table;
+
+    foreach ($s->table as $t) {
+      $table = new MTrackDBSchema_Table;
+      $table->name = (string)$t['name'];
+
+      foreach ($t->field as $f) {
+        $F = new stdclass;
+        foreach ($f->attributes() as $k => $v) {
+          $F->{(string)$k} = (string)$v;
+        }
+        if (isset($f->comment)) {
+          $F->comment = (string)$f->comment;
+        }
+        $table->fields[$F->name] = $F;
+      }
+      foreach ($t->key as $k) {
+        $K = new stdclass;
+        $K->fields = array();
+        if (isset($k['type'])) {
+          $K->type = (string)$k['type'];
+        } else {
+          $K->type = 'primary';
+        }
+        foreach ($k->field as $f) {
+          $K->fields[] = (string)$f;
+        }
+        if (isset($k['name'])) {
+          $K->name = (string)$k['name'];
+        } else {
+          $K->name = sprintf("idx_%s_%s", $table->name, join('_', $K->fields));
+        }
+        $table->keys[$K->name] = $K;
+      }
+
+      $this->tables[$table->name] = $table;
+    }
+    foreach ($s->post as $p) {
+      $this->post[(string)$p['driver']] = (string)$p;
+    }
+
+    /* apply custom ticket fields */
+    if (isset($this->tables['tickets'])) {
+      $table = $this->tables['tickets'];
+      $custom = MTrackTicket_CustomFields::getInstance();
+      foreach ($custom->fields as $field) {
+        $f = new stdclass;
+        $f->name = $field->name;
+        $f->type = 'text';
+        $table->fields[$f->name] = $f;
+      }
+    }
+  }
+}
+
+class MTrackDB {
+  static $db = null;
+  static $extensions = array();
+  static $queries = 0;
+  static $query_strings = array();
+
+  static function registerExtension(IMTrackDBExtension $ext) {
+    self::$extensions[] = $ext;
+  }
+
+  // given a unix timestamp, return a value timestamp string
+  // suitable for use with the database
+  static function unixtime($unix) {
+    list($unix) = explode('.', $unix, 2);
+    if ($unix == 0) {
+      return null;
+    }
+    if ($unix < 10) {
+      throw new Exception("unix time $unix is too small\n");
+    }
+    $d = date_create("@$unix", new DateTimeZone('UTC'));
+    // 2008-12-22T05:42:42.285445Z
+    if (!is_object($d)) {
+      throw new Exception("failed to create date for time $unix");
+    }
+    return $d->format('Y-m-d\TH:i:s.u\Z');
+  }
+
+  static function get() {
+    if (self::$db == null) {
+      $dsn = MTrackConfig::get('core', 'dsn');
+      if ($dsn === null) {
+        $dsn = 'sqlite:' . MTrackConfig::get('core', 'dblocation');
+      }
+      $db = new PDO($dsn);
+      $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+      self::$db = $db;
+
+      if ($db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'sqlite') {
+        $db->sqliteCreateAggregate('mtrack_group_concat',
+            array('MTrackDB', 'group_concat_step'),
+            array('MTrackDB', 'group_concat_final'));
+
+        $db->sqliteCreateFunction('mtrack_cleanup_attachments',
+            array('MTrackAttachment', 'attachment_row_deleted'));
+      }
+
+      foreach (self::$extensions as $ext) {
+        $ext->onHandleCreated($db);
+      }
+    }
+    return self::$db;
+  }
+
+  static function lastInsertId($tablename, $keyfield) {
+    if (!strlen($tablename) || !strlen($keyfield)) {
+      throw new Exception("missing tablename or keyfield");
+    }
+    if (self::$db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'pgsql') {
+      return self::$db->lastInsertId($tablename . '_' . $keyfield . '_seq');
+    } else {
+      return self::$db->lastInsertId();
+    }
+  }
+
+  static function group_concat_step($context, $rownum, $value)
+  {
+    if (!is_array($context)) {
+      $context = array();
+    }
+    $context[] = $value;
+    return $context;
+  }
+
+  static function group_concat_final($context, $rownum)
+  {
+    if ($context === null) {
+      return null;
+    }
+    asort($context);
+    return join(", ", $context);
+  }
+
+  static function esc($str) {
+    return "'" . str_replace("'", "''", $str) . "'";
+  }
+
+  /* issue a query, passing optional parameters */
+  static function q($sql) {
+    self::$queries++;
+    if (isset(self::$query_strings[$sql])) {
+      self::$query_strings[$sql]++;
+    } else {
+      self::$query_strings[$sql] = 1;
+    }
+    $params = func_get_args();
+    array_shift($params);
+    $db = self::get();
+#      echo "<br>SQL: $sql\n";
+#      var_dump($params);
+#echo "<br>";
+    try {
+      if (count($params)) {
+        $q = $db->prepare($sql);
+        $q->execute($params);
+      } else {
+        $q = $db->query($sql);
+      }
+    } catch (Exception $e) {
+      echo $e->getMessage();
+      throw $e;
+    }
+    return $q;
+  }
+}
+
diff --git a/inc/hyperlight/cpp.php b/inc/hyperlight/cpp.php
new file mode 100644 (file)
index 0000000..9d1db03
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+
+// TODO:
+// - Add escaped string characters
+// - Add 'TO DO', 'FIX ME', … tags
+// - (Add doc comments?)
+
+class CppLanguage extends HyperLanguage {
+    public function __construct() {
+        $this->setInfo(array(
+            parent::NAME => 'C++',
+            parent::VERSION => '0.4',
+            parent::AUTHOR => array(
+                parent::NAME => 'Konrad Rudolph',
+                parent::WEBSITE => 'madrat.net',
+                parent::EMAIL => 'konrad_rudolph@madrat.net'
+            )
+        ));
+
+        $this->setExtensions(array('c', 'cc', 'cpp', 'h', 'hpp', 'icl', 'ipp'));
+
+        $keyword = array('keyword' => array('', 'type', 'literal', 'operator'));
+        $common = array(
+            'string', 'char', 'number', 'comment',
+            'keyword' => array('', 'type', 'literal', 'operator'),
+            'identifier',
+            'operator'
+        );
+
+        $this->addStates(array(
+            'init' => array_merge(array('include', 'preprocessor'), $common),
+            'include' => array('incpath'),
+            'preprocessor' => array_merge($common, array('pp_newline')),
+        ));
+
+        $this->addRules(array(
+            'whitespace' => RULE::ALL_WHITESPACE,
+            'operator' => '/<:|:>|<%|%>|%:|%:%:|\+\+|--|&&|\|\||::|<<|>>|##|\.\.\.|\.\*|->|->*|[-+*\/%^&|!~<>.=,;:?()\[\]\{\}]|[-+*\/%^&|=!~<>]=|<<=|>>=/',
+            'include' => new Rule('/#\s*include/', '/\n/'),
+            'preprocessor' => new Rule('/#\s*\w+/', '/\n/'),
+            //'pp_newline' => '/[^\\\\](?<bs>\\\\*?)(?P=bs)\\\\\n/',
+            'pp_newline' => '/(?<!\\\\)(?:\\\\\\\\)*?\\\\\n/',
+            'incpath' => '/<[^>]*>|"[^"]*"/',
+            'string' => Rule::C_DOUBLEQUOTESTRING,
+            'char' => Rule::C_SINGLEQUOTESTRING,
+            'number' => Rule::C_NUMBER,
+            'comment' => Rule::C_COMMENT,
+            'keyword' => array(
+                array(
+                    'asm', 'auto', 'break', 'case', 'catch', 
+                    'const_cast', 'continue', 'default', 'do', 'dynamic_cast',
+                    'else', 'explicit', 'export', 'extern', 'for',
+                    'friend', 'goto', 'if', 'mutable', 'namespace',
+                    'operator', 'private', 'protected', 'public', 'register',
+                    'reinterpret_cast', 'return', 'sizeof',
+                    'static_cast', 'switch', 'template', 'throw',
+                    'try', 'typedef', 'typename', 'using', 'virtual',
+                    'volatile', 'while'
+                ),
+                'type' => array(
+                    'bool', 'char', 'double', 'float', 'int', 'long', 'short',
+                    'signed', 'unsigned', 'void', 'wchar_t', 'struct', 'union',
+                                                                               'class', 'static', 'inline', 'enum', 'const',
+                                                                               'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t',
+                                                                               'int8_t', 'int16_t', 'int32_t', 'int64_t', 'FILE', 'DIR',
+                ),
+                'literal' => array(
+                    'false', 'this', 'true', 'NULL',
+                ),
+                'operator' => array(
+                    'and', 'and_eq', 'bitand', 'bitor', 'compl', 'delete',
+                    'new', 'not', 'not_eq', 'or', 'or_eq', 'typeid', 'xor',
+                    'xor_eq'
+                ),
+            ),
+            'identifier' => Rule::C_IDENTIFIER,
+        ));
+
+        $this->addMappings(array(
+            'operator' => '',
+            'include' => 'preprocessor',
+            'incpath' => 'tag',
+        ));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/csharp.php b/inc/hyperlight/csharp.php
new file mode 100644 (file)
index 0000000..4b935c0
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+class CsharpLanguage extends HyperLanguage {
+    public function __construct() {
+        $this->setInfo(array(
+            parent::NAME => 'C#',
+            parent::VERSION => '0.3',
+            parent::AUTHOR => array(
+                parent::NAME => 'Konrad Rudolph',
+                parent::WEBSITE => 'madrat.net',
+                parent::EMAIL => 'konrad_rudolph@madrat.net'
+            )
+        ));
+
+        $this->setExtensions(array('cs'));
+
+        $this->setCaseInsensitive(false);
+
+        $this->addStates(array(
+            'init' => array(
+                'string',
+                'char',
+                'number',
+                'comment' => array('', 'doc'),
+                'keyword' => array('', 'type', 'literal', 'operator', 'preprocessor'),
+                'identifier',
+                'operator',
+                'whitespace',
+            ),
+            'comment doc' => 'doc',
+        ));
+
+        $this->addRules(array(
+            'whitespace' => Rule::ALL_WHITESPACE,
+            'operator' => '/[-+*\/%&|^!~=<>?{}()\[\].,:;]|&&|\|\||<<|>>|[-=!<>+*\/%&|^]=|<<=|>>=|->/',
+            'string' => Rule::C_DOUBLEQUOTESTRING,
+            'char' => Rule::C_SINGLEQUOTESTRING,
+            'number' => Rule::C_NUMBER,
+            'comment' => array(
+                '#//(?:[^/].*?)?\n|/\*.*?\*/#s',
+                'doc' => new Rule('#///#', '/$/m')
+            ),
+            'doc' => '/<(?:".*?"|\'.*?\'|[^>])*>/',
+            'keyword' => array(
+                array(
+                    'abstract', 'break', 'case', 'catch', 'checked', 'class',
+                    'const', 'continue', 'default', 'delegate', 'do', 'else',
+                    'enum', 'event', 'explicit', 'extern', 'finally', 'fixed',
+                    'for', 'foreach', 'goto', 'if', 'implicit', 'in', 'interface',
+                    'internal', 'lock', 'namespace', 'operator', 'out', 'override',
+                    'params', 'private', 'protected', 'public', 'readonly', 'ref',
+                    'return', 'sealed', 'static', 'struct', 'switch', 'throw',
+                    'try', 'unchecked', 'unsafe', 'using', 'var', 'virtual',
+                    'volatile', 'while'
+                ),
+                'type' => array(
+                    'bool', 'byte', 'char', 'decimal', 'double', 'float', 'int',
+                    'long', 'object', 'sbyte', 'short', 'string', 'uint', 'ulong',
+                    'ushort', 'void'
+                ),
+                'literal' => array(
+                    'base', 'false', 'null', 'this', 'true',
+                ),
+                'operator' => array(
+                    'as', 'is', 'new', 'sizeof', 'stackallock', 'typeof',
+                ),
+                'preprocessor' => '/#(?:if|else|elif|endif|define|undef|warning|error|line|region|endregion)/'
+            ),
+            'identifier' => '/@?[a-z_][a-z0-9_]*/i',
+        ));
+
+        $this->addMappings(array(
+            'whitespace' => '',
+            'operator' => '',
+        ));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/css.php b/inc/hyperlight/css.php
new file mode 100644 (file)
index 0000000..100c7b4
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+
+class CssLanguage extends HyperLanguage {
+    public function __construct() {
+        $this->setInfo(array(
+            parent::NAME => 'CSS',
+            parent::VERSION => '0.8',
+            parent::AUTHOR => array(
+                parent::NAME => 'Konrad Rudolph',
+                parent::WEBSITE => 'madrat.net',
+                parent::EMAIL => 'konrad_rudolph@madrat.net'
+            )
+        ));
+
+        $this->setExtensions(array('css'));
+
+        // The following does not conform to the specs but it is necessary
+        // else numbers wouldn't be recognized any more.
+        $nmstart = '-?[a-z]';
+        $nmchar = '[a-z0-9-]';
+        $hex = '[0-9a-f]';
+        list($string, $strmod) = preg_strip(Rule::STRING);
+        $strmod = implode('', $strmod);
+
+        $this->addStates(array(
+            'init' => array('comment', 'uri', 'meta', 'id', 'class', 'pseudoclass', 'element', 'block', 'constraint', 'string'),
+            'block' => array('comment', 'attribute', 'value'),
+            'constraint' => array('identifier', 'string'),
+            'value' => array('comment', 'string', 'color', 'number', 'uri', 'identifier', 'important'),
+        ));
+
+        $this->addRules(array(
+            'attribute' => "/$nmstart$nmchar*/i",
+            'value' => new Rule('/:/', '/;|(?=\})/'),
+            'comment' => Rule::C_MULTILINECOMMENT,
+            'meta' => "/@$nmstart$nmchar*/i",
+            'id' => "/#$nmstart$nmchar*/i",
+            'class' => "/\.$nmstart$nmchar*/",
+            // Pay attention not to match rules such as ::selection!
+            'pseudoclass' => "/(?<!:):$nmstart$nmchar*/",
+            'element' => "/$nmstart$nmchar*/i",
+            'block' => new Rule('/\{/', '/\}/'),
+            'constraint' => new Rule('/\[/', '/\]/'),
+            'number' => '/[+-]?(?:\d+(\.\d+)?|\d*\.\d+)(%|em|ex|px|pt|in|cm|mm|pc|deg|g?rad|m?s|k?Hz)?/',
+            'uri' => "/url\(\s*(?:$string|[^\)]*)\s*\)/$strmod",
+            'identifier' => "/$nmstart$nmchar*/i",
+            'string' => "/$string/$strmod",
+            'color' => "/#$hex{3}(?:$hex{3})?/i",
+            'important' => '/!\s*important/',
+        ));
+
+        $this->addMappings(array(
+            'element' => 'keyword',
+            'id' => 'keyword type',
+            'class' => 'keyword builtin',
+            'pseudoclass' => 'preprocessor',
+            'block' => '',
+            'constraint' => '',
+            'value' => '',
+            'color' => 'string',
+            'uri' => 'char',
+            'meta' => 'keyword',
+        ));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/hyperlight.php b/inc/hyperlight/hyperlight.php
new file mode 100644 (file)
index 0000000..e7a541a
--- /dev/null
@@ -0,0 +1,1033 @@
+<?php
+
+/*
+ * Copyright 2008 Konrad Rudolph
+ * All rights reserved.
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/*
+ * TODO list
+ * =========
+ *
+ * - FIXME Nested syntax elements create redundant nested tags under certain
+ *   circumstances. This can be reproduced by the following PHP snippet:
+ *
+ *      <pre class="<?php echo; ? >">
+ *
+ *   (Remove space between `?` and `>`).
+ *   Although this no longer occurs, it is fixed by checking for `$token === ''`
+ *   in the `emit*` methods. This should never happen anyway. Probably something
+ *   to do with the zero-width lookahead in the PHP syntax definition.
+ *
+ * - `hyperlight_calculate_fold_marks`: refactor, write proper handler
+ *
+ * - Line numbers (on client-side?)
+ *
+ */
+
+/**
+ * Hyperlight source code highlighter for PHP.
+ * @package hyperlight
+ */
+
+/** @ignore */
+require_once dirname(__FILE__) . '/preg_helper.php';
+
+if (!function_exists('array_peek')) {
+    /**
+     * @internal
+     * This does exactly what you think it does. */
+    function array_peek(array &$array) {
+        $cnt = count($array);
+        return $cnt === 0 ? null : $array[$cnt - 1];
+    }
+}
+
+/**
+ * @internal
+ * For internal debugging purposes.
+ */
+function dump($obj, $descr = null) {
+    if ($descr !== null)
+        echo "<h3>$descr</h3>";
+    ob_start();
+    var_dump($obj);
+    $dump = ob_get_clean();
+    ?><pre><?php echo htmlspecialchars($dump); ?></pre><?php
+    return true;
+}
+
+/**
+ * Raised when the grammar offers a rule that has not been defined.
+ */
+class NoMatchingRuleException extends Exception {
+    /** @internal */
+    public function __construct($states, $position, $code) {
+        $state = array_pop($states);
+        parent::__construct(
+            "State '$state' has no matching rule at position $position:\n" .
+            $this->errorSurrounding($code, $position)
+        );
+    }
+
+    // Try to extract the location of the error more or less precisely.
+    // Only used for a comprehensive display.
+    private function errorSurrounding($code, $pos) {
+        $size = 10;
+        $begin = $pos < $size ? 0 : $pos - $size;
+        $end = $pos + $size > strlen($code) ? strlen($code) : $pos + $size;
+        $offs = $pos - $begin;
+        return substr($code, $begin, $end - $begin) . "\n" . sprintf("%{$offs}s", '^');
+    }
+}
+
+/**
+ * Represents a nesting rule in the grammar of a language definition.
+ *
+ * Individual rules can either be represented by raw strings ("simple" rules) or
+ * by a nesting rule. Nesting rules specify where they can start and end. Inside
+ * a nesting rule, other rules may be applied (both simple and nesting).
+ * For example, a nesting rule may define a string literal. Inside that string,
+ * other rules may be applied that recognize escape sequences.
+ *
+ * To use a nesting rule, supply how it may start and end, e.g.:
+ * <code>
+ * $string_rule = array('string' => new Rule('/"/', '/"/'));
+ * </code>
+ * You also need to specify nested states:
+ * <code>
+ * $string_states = array('string' => 'escaped');
+ * <code>
+ * Now you can add another rule for <var>escaped</var>:
+ * <code>
+ * $escaped_rule = array('escaped' => '/\\(x\d{1,4}|.)/');
+ * </code>
+ */
+class Rule {
+    /**
+     * Common rules.
+     */
+
+    const ALL_WHITESPACE = '/(\s|\r|\n)+/';
+    const C_IDENTIFIER = '/[a-z_][a-z0-9_]*/i';
+    const C_COMMENT = '#//.*?\n|/\*.*?\*/#s';
+    const C_MULTILINECOMMENT = '#/\*.*?\*/#s';
+    const DOUBLEQUOTESTRING = '/"(?:\\\\"|.)*?"/s';
+    const SINGLEQUOTESTRING = "/'(?:\\\\'|.)*?'/s";
+    const C_DOUBLEQUOTESTRING = '/L?"(?:\\\\"|.)*?"/s';
+    const C_SINGLEQUOTESTRING = "/L?'(?:\\\\'|.)*?'/s";
+    const STRING = '/"(?:\\\\"|.)*?"|\'(?:\\\\\'|.)*?\'/s';
+    const C_NUMBER = '/
+        (?: # Integer followed by optional fractional part.
+            (?:
+                0(?:
+                    x[0-9a-f]+
+                    |
+                    [0-7]*
+                )
+                |
+                \d+
+            )
+            (?:\.\d*)?
+            (?:e[+-]\d+)?
+        )
+        |
+        (?: # Just the fractional part.
+            (?:\.\d+)
+            (?:e[+-]?\d+)?
+        )
+        /ix';
+
+    private $_start;
+    private $_end;
+
+    /** @ignore */
+    public function __construct($start, $end = null) {
+        $this->_start = $start;
+        $this->_end = $end;
+    }
+
+    /**
+     * Returns the pattern with which this rule starts.
+     * @return string
+     */
+    public function start() {
+        return $this->_start;
+    }
+
+    /**
+     * Returns the pattern with which this rule may end.
+     * @return string
+     */
+    public function end() {
+        return $this->_end;
+    }
+}
+
+/**
+ * Abstract base class of all Hyperlight language definitions.
+ *
+ * In order to define a new language definition, this class is inherited.
+ * The only function that needs to be overridden is the constructor. Helper
+ * functions from the base class can then be called to construct the grammar
+ * and store additional information.
+ * The name of the subclass must be of the schema <var>{Lang}Language</var>,
+ * where <var>{Lang}</var> is a short, unique name for the language starting
+ * with a capital letter and continuing in lower case. For example,
+ * <var>PhpLanguage</var> is a valid name. The language definition must
+ * reside in a file located at <var>languages/{lang}.php</var>. Here,
+ * <var>{lang}</var> is the all-lowercase spelling of the name, e.g.
+ * <var>languages/php.php</var>.
+ *
+ */
+abstract class HyperLanguage {
+    private $_states = array();
+    private $_rules = array();
+    private $_mappings = array();
+    private $_info = array();
+    private $_extensions = array();
+    private $_caseInsensitive = false;
+    private $_postProcessors = array();
+
+    private static $_languageCache = array();
+    private static $_compiledLanguageCache = array();
+    private static $_filetypes;
+
+    /**
+     * Indices for information.
+     */
+
+    const NAME = 1;
+    const VERSION = 2;
+    const AUTHOR = 10;
+    const WEBSITE = 5;
+    const EMAIL = 6;
+
+    /**
+     * Retrieves a language definition name based on a file extension.
+     *
+     * Uses the contents of the <var>languages/filetypes</var> file to
+     * guess the language definition name from a file name extension.
+     * This file has to be generated using the
+     * <var>collect-filetypes.php</var> script every time the language
+     * definitions have been changed.
+     *
+     * @param string $ext the file name extension.
+     * @return string The language definition name or <var>NULL</var>.
+     */
+    public static function nameFromExt($ext) {
+        if (self::$_filetypes === null) {
+            $ft_content = file('languages/filetypes', 1);
+
+            foreach ($ft_content as $line) {
+                list ($name, $extensions) = explode(':', trim($line));
+                $extensions = explode(',', $extensions);
+                // Inverse lookup.
+                foreach ($extensions as $extension)
+                    $ft_data[$extension] = $name;
+            }
+            self::$_filetypes = $ft_data;
+        }
+        $ext = strtolower($ext);
+        return
+            array_key_exists($ext, self::$_filetypes) ?
+            self::$_filetypes[strtolower($ext)] : null;
+    }
+
+    public static function compile(HyperLanguage $lang) {
+        $id = $lang->id();
+        if (!isset(self::$_compiledLanguageCache[$id]))
+            self::$_compiledLanguageCache[$id] = $lang->makeCompiledLanguage();
+        return self::$_compiledLanguageCache[$id];
+    }
+
+    public static function compileFromName($lang) {
+        return self::compile(self::fromName($lang));
+    }
+
+    protected static function exists($lang) {
+        return isset(self::$_languageCache[$lang]) or
+               file_exists("languages/$lang.php");
+    }
+
+    protected static function fromName($lang) {
+        if (!isset(self::$_languageCache[$lang])) {
+                       require_once dirname(__FILE__) . "/$lang.php";
+            $klass = ucfirst("{$lang}Language");
+            self::$_languageCache[$lang] = new $klass();
+        }
+        return self::$_languageCache[$lang];
+    }
+
+    public function id() {
+        $klass = get_class($this);
+        return strtolower(substr($klass, 0, strlen($klass) - strlen('Language')));
+    }
+
+    protected function setCaseInsensitive($value) {
+        $this->_caseInsensitive = $value;
+    }
+
+    protected function addStates(array $states) {
+        $this->_states = self::mergeProperties($this->_states, $states);
+    }
+
+    protected function getState($key) {
+        return $this->_states[$key];
+    }
+
+    protected function removeState($key) {
+        unset($this->_states[$key]);
+    }
+
+    protected function addRules(array $rules) {
+        $this->_rules = self::mergeProperties($this->_rules, $rules);
+    }
+
+    protected function getRule($key) {
+        return $this->_rules[$key];
+    }
+
+    protected function removeRule($key) {
+        unset($this->_rules[$key]);
+    }
+
+    protected function addMappings(array $mappings) {
+        // TODO Implement nested mappings.
+        $this->_mappings = array_merge($this->_mappings, $mappings);
+    }
+
+    protected function getMapping($key) {
+        return $this->_mappings[$key];
+    }
+
+    protected function removeMapping($key) {
+        unset($this->_mappings[$key]);
+    }
+
+    protected function setInfo(array $info) {
+        $this->_info = $info;
+    }
+
+    protected function setExtensions(array $extensions) {
+        $this->_extensions = $extensions;
+    }
+
+    protected function addPostprocessing($rule, HyperLanguage $language) {
+        $this->_postProcessors[$rule] = $language;
+    }
+
+//    protected function addNestedLanguage(HyperLanguage $language, $hoistBackRules) {
+//        $prefix = get_class($language);
+//        if (!is_array($hoistBackRules))
+//            $hoistBackRules = array($hoistBackRules);
+//
+//        $states = array();  // Step 1: states
+//
+//        foreach ($language->_states as $stateName => $state) {
+//            $prefixedRules = array();
+//
+//            if (strstr($stateName, ' ')) {
+//                $parts = explode(' ', $stateName);
+//                $prefixed = array();
+//                foreach ($parts as $part)
+//                    $prefixed[] = "$prefix$part";
+//                $stateName = implode(' ', $prefixed);
+//            }
+//            else
+//                $stateName = "$prefix$stateName";
+//
+//            foreach ($state as $key => $rule) {
+//                if (is_string($key) and is_array($rule)) {
+//                    $nestedRules = array();
+//                    foreach ($rule as $nestedRule)
+//                        $nestedRules[] = ($nestedRule === '') ? '' :
+//                                         "$prefix$nestedRule";
+//
+//                    $prefixedRules["$prefix$key"] = $nestedRules;
+//                }
+//                else
+//                    $prefixedRules[] = "$prefix$rule";
+//            }
+//
+//            if ($stateName === 'init')
+//                $prefixedRules = array_merge($hoistBackRules, $prefixedRules);
+//
+//            $states[$stateName] = $prefixedRules;
+//        }
+//
+//        $rules = array();   // Step 2: rules
+//        // Mappings need to set up already!
+//        $mappings = array();
+//
+//        foreach ($language->_rules as $ruleName => $rule) {
+//            if (is_array($rule)) {
+//                $nestedRules = array();
+//                foreach ($rule as $nestedName => $nestedRule) {
+//                    if (is_string($nestedName)) {
+//                        $nestedRules["$prefix$nestedName"] = $nestedRule;
+//                        $mappings["$prefix$nestedName"] = $nestedName;
+//                    }
+//                    else
+//                        $nestedRules[] = $nestedRule;
+//                }
+//                $rules["$prefix$ruleName"] = $nestedRules;
+//            }
+//            else {
+//                $rules["$prefix$ruleName"] = $rule;
+//                $mappings["$prefix$ruleName"] = $ruleName;
+//            }
+//        }
+//
+//        // Step 3: mappings.
+//
+//        foreach ($language->_mappings as $ruleName => $cssClass) {
+//            if (strstr($ruleName, ' ')) {
+//                $parts = explode(' ', $ruleName);
+//                $prefixed = array();
+//                foreach ($parts as $part)
+//                    $prefixed[] = "$prefix$part";
+//                $mappings[implode(' ', $prefixed)] = $cssClass;
+//            }
+//            else
+//                $mappings["$prefix$ruleName"] = $cssClass;
+//        }
+//
+//        $this->addStates($states);
+//        $this->addRules($rules);
+//        $this->addMappings($mappings);
+//
+//        return $prefix . 'init';
+//    }
+
+    private function makeCompiledLanguage() {
+        return new HyperlightCompiledLanguage(
+            $this->id(),
+            $this->_info,
+            $this->_extensions,
+            $this->_states,
+            $this->_rules,
+            $this->_mappings,
+            $this->_caseInsensitive,
+            $this->_postProcessors
+        );
+    }
+
+    private static function mergeProperties(array $old, array $new) {
+        foreach ($new as $key => $value) {
+            if (is_string($key)) {
+                if (isset($old[$key]) and is_array($old[$key]))
+                    $old[$key] = array_merge($old[$key], $new);
+                else
+                    $old[$key] = $value;
+            }
+            else
+                $old[] = $value;
+        }
+
+        return $old;
+    }
+}
+
+class HyperlightCompiledLanguage {
+    private $_id;
+    private $_info;
+    private $_extensions;
+    private $_states;
+    private $_rules;
+    private $_mappings;
+    private $_caseInsensitive;
+    private $_postProcessors = array();
+
+    public function __construct($id, $info, $extensions, $states, $rules, $mappings, $caseInsensitive, $postProcessors) {
+        $this->_id = $id;
+        $this->_info = $info;
+        $this->_extensions = $extensions;
+        $this->_caseInsensitive = $caseInsensitive;
+        $this->_states = $this->compileStates($states);
+        $this->_rules = $this->compileRules($rules);
+        $this->_mappings = $mappings;
+
+        foreach ($postProcessors as $ppkey => $ppvalue)
+            $this->_postProcessors[$ppkey] = HyperLanguage::compile($ppvalue);
+    }
+
+    public function id() {
+        return $this->_id;
+    }
+
+    public function name() {
+        return $this->_info[HyperLanguage::NAME];
+    }
+
+    public function authorName() {
+        if (!array_key_exists(HyperLanguage::AUTHOR, $this->_info))
+            return null;
+        $author = $this->_info[HyperLanguage::AUTHOR];
+        if (is_string($author))
+            return $author;
+        if (!array_key_exists(HyperLanguage::NAME, $author))
+            return null;
+        return $author[HyperLanguage::NAME];
+    }
+
+    public function authorWebsite() {
+        if (!array_key_exists(HyperLanguage::AUTHOR, $this->_info) or
+            !is_array($this->_info[HyperLanguage::AUTHOR]) or
+            !array_key_exists(HyperLanguage::WEBSITE, $this->_info[HyperLanguage::AUTHOR]))
+            return null;
+        return $this->_info[HyperLanguage::AUTHOR][HyperLanguage::WEBSITE];
+    }
+
+    public function authorEmail() {
+        if (!array_key_exists(HyperLanguage::AUTHOR, $this->_info) or
+            !is_array($this->_info[HyperLanguage::AUTHOR]) or
+            !array_key_exists(HyperLanguage::EMAIL, $this->_info[HyperLanguage::AUTHOR]))
+            return null;
+        return $this->_info[HyperLanguage::AUTHOR][HyperLanguage::EMAIL];
+    }
+
+    public function authorContact() {
+        $email = $this->authorEmail();
+        return $email !== null ? $email : $this->authorWebsite();
+    }
+
+    public function extensions() {
+        return $this->_extensions;
+    }
+
+    public function state($stateName) {
+        return $this->_states[$stateName];
+    }
+
+    public function rule($ruleName) {
+        return $this->_rules[$ruleName];
+    }
+
+    public function className($state) {
+        if (array_key_exists($state, $this->_mappings))
+            return $this->_mappings[$state];
+        else if (strstr($state, ' ') === false)
+            // No mapping for state.
+            return $state;
+        else {
+            // Try mapping parts of nested state name.
+            $parts = explode(' ', $state);
+            $ret = array();
+
+            foreach ($parts as $part) {
+                if (array_key_exists($part, $this->_mappings))
+                    $ret[] = $this->_mappings[$part];
+                else
+                    $ret[] = $part;
+            }
+
+            return implode(' ', $ret);
+        }
+    }
+
+    public function postProcessors() {
+        return $this->_postProcessors;
+    }
+
+    private function compileStates($states) {
+        $ret = array();
+
+        foreach ($states as $name => $state) {
+            $newstate = array();
+
+            if (!is_array($state))
+                $state = array($state);
+
+            foreach ($state as $key => $elem) {
+                if ($elem === null)
+                    continue;
+                if (is_string($key)) {
+                    if (!is_array($elem))
+                        $elem = array($elem);
+
+                    foreach ($elem as $el2) {
+                        if ($el2 === '')
+                            $newstate[] = $key;
+                        else
+                            $newstate[] = "$key $el2";
+                    }
+                }
+                else
+                    $newstate[] = $elem;
+            }
+
+            $ret[$name] = $newstate;
+        }
+
+        return $ret;
+    }
+
+    private function compileRules($rules) {
+        $tmp = array();
+
+        // Preprocess keyword list and flatten nested lists:
+
+        // End of regular expression matching keywords.
+        $end = $this->_caseInsensitive ? ')\b/i' : ')\b/';
+
+        foreach ($rules as $name => $rule) {
+            if (is_array($rule)) {
+                if (self::isAssocArray($rule)) {
+                    // Array is a nested list of rules.
+                    foreach ($rule as $key => $value) {
+                        if (is_array($value))
+                            // Array represents a list of keywords.
+                            $value = '/\b(?:' . implode('|', $value) . $end;
+
+                        if (!is_string($key) or strlen($key) === 0)
+                            $tmp[$name] = $value;
+                        else
+                            $tmp["$name $key"] = $value;
+                    }
+                }
+                else {
+                    // Array represents a list of keywords.
+                    $rule = '/\b(?:' . implode('|', $rule) . $end;
+                    $tmp[$name] = $rule;
+                }
+            }
+            else {
+                $tmp[$name] = $rule;
+            } // if (is_array($rule))
+        } // foreach
+
+        $ret = array();
+
+        foreach ($this->_states as $name => $state) {
+            $regex_rules = array();
+            $regex_names = array();
+            $nesting_rules = array();
+
+            foreach ($state as $rule_name) {
+                $rule = $tmp[$rule_name];
+                if ($rule instanceof Rule)
+                    $nesting_rules[$rule_name] = $rule;
+                else {
+                    $regex_rules[] = $rule;
+                    $regex_names[] = $rule_name;
+                }
+            }
+
+            $ret[$name] = array_merge(
+                array(preg_merge('|', $regex_rules, $regex_names)),
+                $nesting_rules
+            );
+        }
+
+        return $ret;
+    }
+
+    private static function isAssocArray(array $array) {
+        foreach($array as $key => $_)
+            if (is_string($key))
+                return true;
+        return false;
+    }
+}
+
+class Hyperlight {
+    private $_lang;
+    private $_result;
+    private $_states;
+    private $_omitSpans;
+    private $_postProcessors = array();
+
+    public function __construct($lang) {
+        if (is_string($lang))
+            $this->_lang = HyperLanguage::compileFromName(strtolower($lang));
+        else if ($lang instanceof HyperlightCompiledLanguage)
+            $this->_lang = $lang;
+        else if ($lang instanceof HyperLanguage)
+            $this->_lang = HyperLanguage::compile($lang);
+        else
+            trigger_error(
+                'Invalid argument type for $lang to Hyperlight::__construct',
+                E_USER_ERROR
+            );
+
+        foreach ($this->_lang->postProcessors() as $ppkey => $ppvalue)
+            $this->_postProcessors[$ppkey] = new Hyperlight($ppvalue);
+
+        $this->reset();
+    }
+
+    public function language() {
+        return $this->_lang;
+    }
+
+    public function reset() {
+        $this->_states = array('init');
+        $this->_omitSpans = array();
+    }
+
+    public function render($code) {
+        // Normalize line breaks.
+        $this->_code = preg_replace('/\r\n?/', "\n", $code);
+        $fm = hyperlight_calculate_fold_marks($this->_code, $this->language()->id());
+        return hyperlight_apply_fold_marks($this->renderCode(), $fm);
+    }
+
+    public function renderAndPrint($code) {
+        echo $this->render($code);
+    }
+
+
+    private function renderCode() {
+        $code = $this->_code;
+        $pos = 0;
+        $len = strlen($code);
+        $this->_result = '';
+        $state = array_peek($this->_states);
+
+        // If there are open states (reentrant parsing), open the corresponding
+        // tags first:
+
+        for ($i = 1; $i < count($this->_states); ++$i)
+            if (!$this->_omitSpans[$i - 1]) {
+                $class = $this->_lang->className($this->_states[$i]);
+                $this->write("<span class=\"$class\">");
+            }
+
+        // Emergency break to catch faulty rules.
+        $prev_pos = -1;
+
+        while ($pos < $len) {
+            // The token next to the current position, after the inner loop completes.
+            // i.e. $closest_hit = array($matched_text, $position)
+            $closest_hit = array('', $len);
+            // The rule that found this token.
+            $closest_rule = null;
+            $rules = $this->_lang->rule($state);
+
+            foreach ($rules as $name => $rule) {
+                if ($rule instanceof Rule)
+                    $this->matchIfCloser(
+                        $rule->start(), $name, $pos, $closest_hit, $closest_rule
+                    );
+                else if (preg_match($rule, $code, $matches, PREG_OFFSET_CAPTURE, $pos) == 1) {
+                    // Search which of the sub-patterns matched.
+
+                    foreach ($matches as $group => $match) {
+                        if (!is_string($group))
+                            continue;
+                        if ($match[1] !== -1) {
+                            $closest_hit = $match;
+                            $closest_rule = str_replace('_', ' ', $group);
+                            break;
+                        }
+                    }
+                }
+            } // foreach ($rules)
+
+            // If we're currently inside a rule, check whether we've come to the
+            // end of it, or the end of any other rule we're nested in.
+
+            if (count($this->_states) > 1) {
+                $n = count($this->_states) - 1;
+                do {
+                    $rule = $this->_lang->rule($this->_states[$n - 1]);
+                    $rule = $rule[$this->_states[$n]];
+                    --$n;
+                    if ($n < 0)
+                        throw new NoMatchingRuleException($this->_states, $pos, $code);
+                } while ($rule->end() === null);
+
+                $this->matchIfCloser($rule->end(), $n + 1, $pos, $closest_hit, $closest_rule);
+            }
+
+            // We take the closest hit:
+
+            if ($closest_hit[1] > $pos)
+                $this->emit(substr($code, $pos, $closest_hit[1] - $pos));
+
+            $prev_pos = $pos;
+            $pos = $closest_hit[1] + strlen($closest_hit[0]);
+
+            if ($prev_pos === $pos and is_string($closest_rule))
+                if (array_key_exists($closest_rule, $this->_lang->rule($state))) {
+                    array_push($this->_states, $closest_rule);
+                    $state = $closest_rule;
+                    $this->emitPartial('', $closest_rule);
+                }
+
+            if ($closest_hit[1] === $len)
+                break;
+            else if (!is_string($closest_rule)) {
+                // Pop state.
+                if (count($this->_states) <= $closest_rule)
+                    throw new NoMatchingRuleException($this->_states, $pos, $code);
+
+                while (count($this->_states) > $closest_rule + 1) {
+                    $lastState = array_pop($this->_states);
+                    $this->emitPop('', $lastState);
+                }
+                $lastState = array_pop($this->_states);
+                $state = array_peek($this->_states);
+                $this->emitPop($closest_hit[0], $lastState);
+            }
+            else if (array_key_exists($closest_rule, $this->_lang->rule($state))) {
+                // Push state.
+                array_push($this->_states, $closest_rule);
+                $state = $closest_rule;
+                $this->emitPartial($closest_hit[0], $closest_rule);
+            }
+            else
+                $this->emit($closest_hit[0], $closest_rule);
+        } // while ($pos < $len)
+
+        // Close any tags that are still open (can happen in incomplete code
+        // fragments that don't necessarily signify an error (consider PHP
+        // embedded in HTML, or a C++ preprocessor code not ending on newline).
+        
+        $omitSpansBackup = $this->_omitSpans;
+        for ($i = count($this->_states); $i > 1; --$i)
+            $this->emitPop();
+        $this->_omitSpans = $omitSpansBackup;
+
+        return $this->_result;
+    }
+
+    private function matchIfCloser($expr, $next, $pos, &$closest_hit, &$closest_rule) {
+        $matches = array();
+        if (preg_match($expr, $this->_code, $matches, PREG_OFFSET_CAPTURE, $pos) == 1) {
+            if (
+                (
+                    // Two hits at same position -- compare length
+                    // For equal lengths: first come, first serve.
+                    $matches[0][1] == $closest_hit[1] and
+                    strlen($matches[0][0]) > strlen($closest_hit[0])
+                ) or
+                $matches[0][1] < $closest_hit[1]
+            ) {
+                $closest_hit = $matches[0];
+                $closest_rule = $next;
+            }
+        }
+    }
+
+    private function processToken($token) {
+        if ($token === '')
+            return '';
+        $nest_lang = array_peek($this->_states);
+        if (array_key_exists($nest_lang, $this->_postProcessors))
+            return $this->_postProcessors[$nest_lang]->render($token);
+        else
+            #return self::htmlentities($token);
+            return htmlspecialchars($token, ENT_NOQUOTES);
+    }
+
+    private function emit($token, $class = '') {
+        $token = $this->processToken($token);
+        if ($token === '')
+            return;
+        $class = $this->_lang->className($class);
+        if ($class === '')
+            $this->write($token);
+        else
+            $this->write("<span class=\"$class\">$token</span>");
+    }
+
+    private function emitPartial($token, $class) {
+        $token = $this->processToken($token);
+        $class = $this->_lang->className($class);
+        if ($class === '') {
+            if ($token !== '')
+                $this->write($token);
+            array_push($this->_omitSpans, true);
+        }
+        else {
+            $this->write("<span class=\"$class\">$token");
+            array_push($this->_omitSpans, false);
+        }
+    }
+
+    private function emitPop($token = '', $class = '') {
+        $token = $this->processToken($token);
+        if (array_pop($this->_omitSpans))
+            $this->write($token);
+        else
+            $this->write("$token</span>");
+    }
+
+    private function write($text) {
+        $this->_result .= $text;
+    }
+
+//      // DAMN! What did I need them for? Something to do with encoding …
+//      // but why not use the `$charset` argument on `htmlspecialchars`?
+//    private static function htmlentitiesCallback($match) {
+//        switch ($match[0]) {
+//            case '<': return '&lt;';
+//            case '>': return '&gt;';
+//            case '&': return '&amp;';
+//        }
+//    }
+//
+//    private static function htmlentities($text) {
+//        return htmlspecialchars($text, ENT_NOQUOTES);
+//        return preg_replace_callback(
+//            '/[<>&]/', array('Hyperlight', 'htmlentitiesCallback'), $text
+//        );
+//    }
+} // class Hyperlight
+
+/**
+ * <var>echo</var>s a highlighted code.
+ *
+ * For example, the following
+ * <code>
+ * hyperlight('<?php echo \'Hello, world\'; ?>', 'php');
+ * </code>
+ * results in:
+ * <code>
+ * <pre class="source-code php">...</pre>
+ * </code>
+ *
+ * @param string $code The code.
+ * @param string $lang The language of the code.
+ * @param string $tag The surrounding tag to use. Optional.
+ * @param array $attributes Attributes to decorate {@link $tag} with.
+ *          If no tag is given, this argument can be passed in its place. This
+ *          behaviour will be assumed if the third argument is an array.
+ *          Attributes must be given as a hash of key value pairs.
+ */
+function hyperlight($code, $lang, $tag = 'pre', array $attributes = array()) {
+    if ($code == '')
+        die("`hyperlight` needs a code to work on!");
+    if ($lang == '')
+        die("`hyperlight` needs to know the code's language!");
+    if (is_array($tag) and !empty($attributes))
+        die("Can't pass array arguments for \$tag *and* \$attributes to `hyperlight`!");
+    if ($tag == '')
+        $tag = 'pre';
+    if (is_array($tag)) {
+        $attributes = $tag;
+        $tag = 'pre';
+    }
+    $lang = htmlspecialchars(strtolower($lang));
+    $class = "source-code $lang";
+
+    $attr = array();
+    foreach ($attributes as $key => $value) {
+        if ($key == 'class')
+            $class .= ' ' . htmlspecialchars($value);
+        else
+            $attr[] = htmlspecialchars($key) . '="' .
+                      htmlspecialchars($value) . '"';
+    }
+
+    $attr = empty($attr) ? '' : ' ' . implode(' ', $attr);
+
+    $hl = new Hyperlight($lang);
+    echo "<$tag class=\"$class\"$attr>";
+    $hl->renderAndPrint(trim($code));
+    echo "</$tag>";
+}
+
+/**
+ * Is the same as:
+ * <code>
+ * hyperlight(file_get_contents($filename), $lang, $tag, $attributes);
+ * </code>
+ * @see hyperlight()
+ */
+function hyperlight_file($filename, $lang = null, $tag = 'pre', array $attributes = array()) {
+    if ($lang == '') {
+        // Try to guess it from file extension.
+        $pos = strrpos($filename, '.');
+        if ($pos !== false) {
+            $ext = substr($filename, $pos + 1);
+            $lang = HyperLanguage::nameFromExt($ext);
+        }
+    }
+    hyperlight(file_get_contents($filename), $lang, $tag, $attributes);
+}
+
+if (defined('HYPERLIGHT_SHORTCUT')) {
+    function hy() {
+        $args = func_get_args();
+        call_user_func_array('hyperlight', $args);
+    }
+    function hyf() {
+        $args = func_get_args();
+        call_user_func_array('hyperlight_file', $args);
+    }
+}
+
+function hyperlight_calculate_fold_marks($code, $lang) {
+    $supporting_languages = array('csharp', 'vb');
+
+    if (!in_array($lang, $supporting_languages))
+        return array();
+
+    $fold_begin_marks = array('/^\s*#Region/', '/^\s*#region/');
+    $fold_end_marks = array('/^\s*#End Region/', '/\s*#endregion/');
+
+    $lines = preg_split('/\r|\n|\r\n/', $code);
+
+    $fold_begin = array();
+    foreach ($fold_begin_marks as $fbm)
+        $fold_begin = $fold_begin + preg_grep($fbm, $lines);
+
+    $fold_end = array();
+    foreach ($fold_end_marks as $fem)
+        $fold_end = $fold_end + preg_grep($fem, $lines);
+
+    if (count($fold_begin) !== count($fold_end) or count($fold_begin) === 0)
+        return array();
+
+    $fb = array();
+    $fe = array();
+    foreach ($fold_begin as $line => $_)
+        $fb[] = $line;
+
+    foreach ($fold_end as $line => $_)
+        $fe[] = $line;
+
+    $ret = array();
+    for ($i = 0; $i < count($fb); $i++)
+        $ret[$fb[$i]] = $fe[$i];
+
+    return $ret;
+}
+
+function hyperlight_apply_fold_marks($code, array $fold_marks) {
+    if ($fold_marks === null or count($fold_marks) === 0)
+        return $code;
+
+    $lines = explode("\n", $code);
+
+    foreach ($fold_marks as $begin => $end) {
+        $lines[$begin] = '<span class="fold-header">' . $lines[$begin] . '<span class="dots"> </span></span>';
+        $lines[$begin + 1] = '<span class="fold">' . $lines[$begin + 1];
+        $lines[$end + 1] = '</span>' . $lines[$end + 1];
+    }
+
+    return implode("\n", $lines);
+}
+
+?>
diff --git a/inc/hyperlight/iphp.php b/inc/hyperlight/iphp.php
new file mode 100644 (file)
index 0000000..762a722
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+require_once('php.php');
+
+class IphpLanguage extends PhpLanguage {
+    public function __construct() {
+        parent::__construct();
+        $this->setExtensions(array()); // Not a whole file, just a fragment.
+        $this->removeState('init');
+        $this->addStates(array('init' => $this->getState('php')));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/javascript.php b/inc/hyperlight/javascript.php
new file mode 100644 (file)
index 0000000..cda76e0
--- /dev/null
@@ -0,0 +1,46 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class JavascriptLanguage extends HyperLanguage {
+  public function __construct() {
+    $this->setInfo(array(
+          parent::NAME => 'Javascript',
+          ));
+    $this->setExtensions(array('js', 'json'));
+    $this->setCaseInsensitive(false);
+    $this->addStates(array(
+          'init' => array(
+            'string',
+            'char',
+            'number',
+            'comment',
+            'keyword' => array('', 'literal', 'operator'),
+            'identifier'
+            ),
+          ));
+
+    $this->addRules(array(
+          'string' => Rule::C_DOUBLEQUOTESTRING,
+          'char' => Rule::C_SINGLEQUOTESTRING,
+          'number' => Rule::C_NUMBER,
+          'comment' => Rule::C_COMMENT,
+          'keyword' => array(
+            array(
+              'assert', 'break', 'class', 'continue',
+              'else', 'except', 'finally', 'for',
+              'if', 'in', 'function',
+              'throw', 'return', 'try', 'while', 'with', 'typeof'
+              ),
+            'literal' => array(
+              'false', 'null', 'true'
+              ),
+            'operator' => '/[-+*\/%&|^!~=<>?{}()\[\].,:;]|&&|\|\||<<|>>|[-=!<>+*\/%&|^]=|<<=|>>=|->/',
+            ),
+          'identifier' => Rule::C_IDENTIFIER,
+          ));
+        $this->addMappings(array(
+            'char' => 'string',
+        ));
+
+  }
+}
diff --git a/inc/hyperlight/perl.php b/inc/hyperlight/perl.php
new file mode 100644 (file)
index 0000000..1cf7da4
--- /dev/null
@@ -0,0 +1,60 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class PerlLanguage extends HyperLanguage {
+  public function __construct() {
+    $this->setInfo(array(
+          parent::NAME => 'Perl',
+          ));
+    $this->setExtensions(array('pl'));
+    $this->setCaseInsensitive(false);
+    $this->addStates(array(
+          'init' => array(
+            'string',
+            'number',
+            'char',
+            'ticked',
+            'variable',
+            'comment',
+            'keyword' => array('', 'operator'),
+            'identifier'
+            ),
+          'variable' => array('identifier'),
+          )
+    );
+
+    $this->addRules(array(
+          'string' => Rule::C_DOUBLEQUOTESTRING,
+          'char' => Rule::C_SINGLEQUOTESTRING,
+          'ticked' => "/\`(?:\\\`|.)*\`/sU",
+          'number' => Rule::C_NUMBER,
+          'comment' => '/#.*/',
+          'keyword' => array(
+            array(
+              'use', 'my', 'our', 'open', 'close', 'tie',
+              'exists', 'keys', 'values', 'chomp',
+              'last', 'next', 'print', 'unless',
+              'and', 'or', 'not', 'defined', 'undef',
+              'push', 'unshift', 'shift', 'pop',
+              'system', 'exec', 'goto', 'uc', 'lc',
+              'length', 'split',
+              'sort', 'grep', 'map', 'die', 'eval',
+              'require', 'bless', 'sub', 'package',
+              'eq', 'ne', 'le', 'lt', 'ge', 'gt',
+              'else', 'for', 'foreach', 'then',
+              'if', 'in', 'case', 'esac', 'while',
+              'end', 'do', 'return', 'elsif', 'exit'
+              ),
+            'operator' => '/&&|\|\||<<|>>|\.=|==|=~|!~|[=;&|!<>\[\].]/',
+            ),
+          'identifier' => Rule::C_IDENTIFIER,
+          'variable' => new Rule('/(@\$|%\$|&\$|@|%|&|\$)/', '//'),
+          ));
+        $this->addMappings(array(
+            'char' => 'string',
+            'variable' => 'tag',
+            'ticked' => 'string',
+        ));
+
+  }
+}
diff --git a/inc/hyperlight/php.php b/inc/hyperlight/php.php
new file mode 100644 (file)
index 0000000..8cc8360
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+// TODO
+// - Fill the scaffold below!
+// - More keywords? What about functions?
+// - String interpolation and escaping.
+// - Usual stuff for doc comments
+// - Heredoc et al.
+// - More complex nested variable names.
+
+class PhpLanguage extends HyperLanguage {
+    public function __construct() {
+        $this->setInfo(array(
+            parent::NAME => 'PHP',
+            parent::VERSION => '0.3',
+            parent::AUTHOR => array(
+                parent::NAME => 'Konrad Rudolph',
+                parent::WEBSITE => 'madrat.net',
+                parent::EMAIL => 'konrad_rudolph@madrat.net'
+            )
+        ));
+
+        $this->setExtensions(array('php', 'php3', 'php4', 'php5', 'inc'));
+
+        $this->addPostProcessing('html', HyperLanguage::fromName('xml'));
+
+        $this->addStates(array(
+            'init' => array('php', 'html'),
+            'php' => array(
+                'comment', 'string', 'char', 'number',
+                'keyword' => array('', 'type', 'literal', 'operator', 'builtin'),
+                'identifier', 'variable'),
+            'variable' => array('identifier'),
+            'html' => array()
+        ));
+
+        $this->addRules(array(
+            'php' => new Rule('/<\?php/', '/\?>/'),
+            'html' => new Rule('/(?=.)/', '/(?=<\?php)/'),
+            'comment' => ",#[^\n]*\n|//.*?\n|/\*.*?\*/,s",
+            'string' => Rule::C_DOUBLEQUOTESTRING,
+            'char' => Rule::C_SINGLEQUOTESTRING,
+            'number' => Rule::C_NUMBER,
+            'identifier' => Rule::C_IDENTIFIER,
+            'variable' => new Rule('/\$/', '//'),
+            'keyword' => array(
+                array('break', 'case', 'class', 'const', 'continue', 'declare', 'default', 'do', 'else', 'elseif', 'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'extends', 'for', 'foreach', 'function', 'global', 'if', 'return', 'static', 'switch', 'use', 'var', 'while', 'final', 'interface', 'implements', 'public', 'private', 'protected', 'abstract', 'try', 'catch', 'throw', 'final', 'namespace'),
+                'type' => array('exception', 'int'),
+                'literal' => array('false', 'null', 'true', 'this'),
+                'operator' => array('and', 'as', 'or', 'xor', 'new', 'instanceof', 'clone'),
+                'builtin' => array('array', 'die', 'echo', 'empty', 'eval', 'exit', 'include', 'include_once', 'isset', 'list', 'print', 'require', 'require_once', 'unset')
+            ),
+        ));
+
+        $this->addMappings(array(
+            'char' => 'string',
+            'variable' => 'tag',
+            'html' => 'preprocessor',
+        ));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/preg_helper.php b/inc/hyperlight/preg_helper.php
new file mode 100644 (file)
index 0000000..798aa19
--- /dev/null
@@ -0,0 +1,170 @@
+<?php
+
+/**
+ * Copyright 2008 Konrad Rudolph
+ * All rights reserved.
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Helper functions for the Perl-compatible regular expressions.
+ * @package preg_helper
+ */
+
+/**
+ * Merges several regular expressions into one, using the indicated 'glue'.
+ *
+ * This function takes care of individual modifiers so it's safe to use
+ * <i>different</i> modifiers on the individual expressions. The order of
+ * sub-matches is preserved as well. Numbered back-references are adapted to
+ * the new overall sub-match count. This means that it's safe to use numbered
+ * back-refences in the individual expressions!
+ * If {@link $names} is given, the individual expressions are captured in
+ * named sub-matches using the contents of that array as names.
+ * Matching pair-delimiters (e.g. <var>"{…}"</var>) are currently
+ * <b>not</b> supported.
+ *
+ * The function assumes that all regular expressions are well-formed.
+ * Behaviour is undefined if they aren't.
+ *
+ * This function was created after a
+ * {@link http://stackoverflow.com/questions/244959/ StackOverflow discussion}.
+ * Much of it was written or thought of by “porneL” and “eyelidlessness”. Many
+ * thanks to both of them.
+ *
+ * @param string $glue  A string to insert between the individual expressions.
+ *      This should usually be either the empty string, indicating
+ *      concatenation, or the pipe (<var>"|"</var>), indicating alternation.
+ *      Notice that this string might have to be escaped since it is treated
+ *      as a normal character in a regular expression (i.e. <var>"/"</var> will
+ *      end the expression and result in an invalid output).
+ * @param array $expressions    The expressions to merge. The expressions may
+ *      have arbitrary different delimiters and modifiers.
+ * @param array $names  Optional. This is either an empty array or an array of
+ *      strings of the same length as {@link $expressions}. In that case,
+ *      the strings of this array are used to create named sub-matches for the
+ *      expressions.
+ * @return string An string representing a regular expression equivalent to the
+ *      merged expressions. Returns <var>FALSE</var> if an error occurred.
+ */
+function preg_merge($glue, array $expressions, array $names = array()) {
+    // … then, a miracle occurs.
+
+    // Sanity check …
+
+    $use_names = ($names !== null and count($names) !== 0);
+
+    if (
+        $use_names and count($names) !== count($expressions) or
+        !is_string($glue)
+    )
+        return false;
+
+    $result = array();
+    // For keeping track of the names for sub-matches.
+    $names_count = 0;
+    // For keeping track of *all* captures to re-adjust backreferences.
+    $capture_count = 0;
+
+    foreach ($expressions as $expression) {
+        if ($use_names)
+            $name = str_replace(' ', '_', $names[$names_count++]);
+
+        // Get delimiters and modifiers:
+
+        $stripped = preg_strip($expression);
+
+        if ($stripped === false)
+            return false;
+
+        list($sub_expr, $modifiers) = $stripped;
+
+        // Re-adjust backreferences:
+        // TODO What about \R backreferences (\0 isn't allowed, though)?
+        
+        // We assume that the expression is correct and therefore don't check
+        // for matching parentheses.
+        
+        $number_of_captures = preg_match_all('/\([^?]|\(\?[^:]/', $sub_expr, $_);
+
+        if ($number_of_captures === false)
+            return false;
+
+        if ($number_of_captures > 0) {
+            $backref_expr = '/
+                (?<!\\\\)        # Not preceded by a backslash,
+                ((?:\\\\\\\\)*?) # zero or more escaped backslashes,
+                \\\\ (\d+)       # followed by backslash plus digits.
+            /x';
+            $sub_expr = preg_replace_callback(
+                $backref_expr,
+                create_function(
+                    '$m',
+                    'return $m[1] . "\\\\" . ((int)$m[2] + ' . $capture_count . ');'
+                ),
+                $sub_expr
+            );
+            $capture_count += $number_of_captures;
+        }
+
+        // Last, construct the new sub-match:
+        
+        $modifiers = implode('', $modifiers);
+        $sub_modifiers = "(?$modifiers)";
+        if ($sub_modifiers === '(?)')
+            $sub_modifiers = '';
+
+        $sub_name = $use_names ? "?<$name>" : '?:';
+        $new_expr = "($sub_name$sub_modifiers$sub_expr)";
+        $result[] = $new_expr;
+    }
+
+    return '/' . implode($glue, $result) . '/';
+}
+
+/**
+ * Strips a regular expression string off its delimiters and modifiers.
+ * Additionally, normalizes the delimiters (i.e. reformats the pattern so that
+ * it could have used <var>"/"</var> as delimiter).
+ *
+ * @param string $expression The regular expression string to strip.
+ * @return array An array whose first entry is the expression itself, the
+ *      second an array of delimiters. If the argument is not a valid regular
+ *      expression, returns <var>FALSE</var>.
+ *
+ */
+function preg_strip($expression) {
+    if (preg_match('/^(.)(.*)\\1([imsxeADSUXJu]*)$/s', $expression, $matches) !== 1)
+        return false;
+
+    $delim = $matches[1];
+    $sub_expr = $matches[2];
+    if ($delim !== '/') {
+        // Replace occurrences by the escaped delimiter by its unescaped
+        // version and escape new delimiter.
+        $sub_expr = str_replace("\\$delim", $delim, $sub_expr);
+        $sub_expr = str_replace('/', '\\/', $sub_expr);
+    }
+    $modifiers = $matches[3] === '' ? array() : str_split(trim($matches[3]));
+
+    return array($sub_expr, $modifiers);
+}
+
+?>
diff --git a/inc/hyperlight/python.php b/inc/hyperlight/python.php
new file mode 100644 (file)
index 0000000..c7167ee
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+# TODO
+# Implement correct number formats (don't forget imaginaries)
+# Implement correct string/bytes formats (escape sequences)
+# Add type “keywords”?
+# <http://docs.python.org/dev/3.0/reference/lexical_analysis.html>
+
+class PythonLanguage extends HyperLanguage {
+    public function __construct() {
+        $this->setInfo(array(
+            parent::NAME => 'Python',
+            parent::VERSION => '0.1',
+            parent::AUTHOR => array(
+                parent::NAME => 'Konrad Rudolph',
+                parent::WEBSITE => 'madrat.net',
+                parent::EMAIL => 'konrad_rudolph@madrat.net'
+            )
+        ));
+
+        $this->setExtensions(array('py'));
+
+        $this->setCaseInsensitive(false);
+
+        $this->addStates(array(
+            'init' => array(
+                'string',
+                'bytes',
+                'number',
+                'comment',
+                'keyword' => array('', 'literal', 'operator'),
+                'identifier'
+            ),
+        ));
+
+        $this->addRules(array(
+            'string' => Rule::C_DOUBLEQUOTESTRING,
+            'bytes' => Rule::C_SINGLEQUOTESTRING,
+            'number' => Rule::C_NUMBER,
+            'comment' => '/#.*/',
+            'keyword' => array(
+                array(
+                    'assert', 'break', 'class', 'continue', 'def', 'del',
+                    'elif', 'else', 'except', 'finally', 'for', 'from',
+                    'global', 'if', 'import', 'in', 'lambda', 'nonlocal',
+                    'pass', 'raise', 'return', 'try', 'while', 'with', 'yield'
+                ),
+                'literal' => array(
+                    'False', 'None', 'True'
+                ),
+                'operator' => array(
+                    'and', 'as', 'is', 'not', 'or'
+                )
+            ),
+            'identifier' => Rule::C_IDENTIFIER,
+        ));
+
+        $this->addMappings(array(
+            'bytes' => 'char'
+        ));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/shell.php b/inc/hyperlight/shell.php
new file mode 100644 (file)
index 0000000..d8a8191
--- /dev/null
@@ -0,0 +1,44 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class ShellLanguage extends HyperLanguage {
+  public function __construct() {
+    $this->setInfo(array(
+          parent::NAME => 'Shell',
+          ));
+    $this->setExtensions(array('sh'));
+    $this->setCaseInsensitive(false);
+    $this->addStates(array(
+          'init' => array(
+            'string',
+            'char',
+            'ticked',
+            'comment',
+            'keyword' => array('', 'operator'),
+            'identifier'
+            ),
+          ));
+
+    $this->addRules(array(
+          'string' => Rule::C_DOUBLEQUOTESTRING,
+          'char' => Rule::C_SINGLEQUOTESTRING,
+          'ticked' => "/\`(?:\\\`|.)*\`/sU",
+          'comment' => '/#.*/',
+          'keyword' => array(
+            array(
+              'break', 'test', 'continue',
+              'else', 'for', 'then',
+              'if', 'in', 'case', 'esac', 'while',
+              'end', 'fi', 'until', 'return', 'elif', 'exit'
+              ),
+            'operator' => '/[;&|!<>\[\]]|&&|\$\(\(|\$\(|\)\)|\)|\(\|\||<<|>>|=|==/',
+            ),
+          'identifier' => Rule::C_IDENTIFIER,
+          ));
+        $this->addMappings(array(
+            'char' => 'string',
+            'ticked' => 'string',
+        ));
+
+  }
+}
diff --git a/inc/hyperlight/vb.php b/inc/hyperlight/vb.php
new file mode 100644 (file)
index 0000000..f6a9327
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+
+class VbLanguage extends HyperLanguage {
+    public function __construct() {
+        $this->setInfo(array(
+            parent::NAME => 'VB',
+            parent::VERSION => '1.4',
+            parent::AUTHOR => array(
+                parent::NAME => 'Konrad Rudolph',
+                parent::WEBSITE => 'madrat.net',
+                parent::EMAIL => 'konrad_rudolph@madrat.net'
+            )
+        ));
+
+        $this->setExtensions(array('vb'));
+
+        $this->setCaseInsensitive(true);
+
+        $this->addStates(array(
+            'init' => array(
+                'string',
+                'number',
+                'comment' => array('', 'doc'),
+                'keyword' => array('', 'type', 'literal', 'operator', 'preprocessor'),
+                'date',
+                'identifier',
+                'operator',
+                'whitespace',
+            ),
+            'string' => 'escaped',
+            'comment doc' => 'doc',
+        ));
+
+        $this->addRules(array(
+            'whitespace' => Rule::ALL_WHITESPACE,
+            'operator' => '/[-+*\/\\\\^&.=,()<>{}]/',
+            'string' => new Rule('/"/', '/"c?/i'),
+            'number' => '/(?: # Integer followed by optional fractional part.
+                (?:&(?:H[0-9a-f]+|O[0-7]+)|\d+)
+                (?:\.\d*)?
+                (?:e[+-]\d+)?
+                U?[SILDFR%@!#&]?
+            )
+            |
+            (?: # Just the fractional part.
+                (?:\.\d+)
+                (?:e[+-]\d+)?
+                [FR!#]?
+            )
+            /ix',
+            'escaped' => '/""/',
+            'keyword' => array(
+                array(
+                    'addhandler', 'addressof', 'alias', 'as', 'byref', 'byval',
+                    'call', 'case', 'catch', 'cbool', 'cbyte', 'cchar',
+                    'cdate', 'cdec', 'cdbl', 'cint', 'class', 'clng', 'cobj',
+                    'const', 'continue', 'csbyte', 'cshort', 'csng', 'cstr',
+                    'ctype', 'cuint', 'culng', 'cushort', 'declare', 'default',
+                    'delegate', 'dim', 'directcast', 'do', 'each', 'else',
+                    'elseif', 'end', 'endif', 'enum', 'erase', 'error',
+                    'event', 'exit', 'finally', 'for', 'friend', 'function',
+                    'get', 'gettype', 'getxmlnamespace', 'global', 'gosub',
+                    'goto', 'handles', 'if', 'implements', 'imports', 'in',
+                    'inherits', 'interface', 'let', 'lib', 'loop', 'module',
+                    'mustinherit', 'mustoverride', 'namespace', 'narrowing',
+                    'next', 'notinheritable', 'notoverridable', 'of', 'on',
+                    'operator', 'option', 'optional', 'overloads',
+                    'overridable', 'overrides', 'paramarray', 'partial',
+                    'private', 'property', 'protected', 'public', 'raiseevent',
+                    'readonly', 'redim', 'removehandler', 'resume', 'return',
+                    'select', 'set', 'shadows', 'shared', 'static', 'step',
+                    'stop', 'structure', 'sub', 'synclock', 'then', 'throw',
+                    'to', 'try', 'trycast', 'wend', 'using', 'when', 'while',
+                    'widening', 'with', 'withevents', 'writeonly'
+                ),
+                'type' => array(
+                    'boolean', 'byte', 'char', 'date', 'decimal', 'double',
+                    'long', 'integer', 'object', 'sbyte', 'short', 'single',
+                    'string', 'variant', 'uinteger', 'ulong', 'ushort'
+                ),
+                'literal' => array(
+                    'false', 'me', 'mybase', 'myclass', 'nothing', 'true'
+                ),
+                'operator' => array(
+                    'and', 'andalso', 'is', 'isnot', 'like', 'mod', 'new',
+                    'not', 'or', 'orelse', 'typeof', 'xor'
+                ),
+                'preprocessor' => '/#(?:const|else|elseif|end if|end region|if|region)/i'
+            ),
+            'comment' => array(
+                "/(?:'{1,2}[^']|rem\s).*/i",
+                'doc' => new Rule("/'''/", '/$/m')
+            ),
+            'date' => '/#.+?#/',
+            'identifier' => '/[a-z_][a-z_0-9]*|\[.+?\]/i',
+            'doc' => '/<(?:".*?"|\'.*?\'|[^>])*>/',
+        ));
+
+        $this->addMappings(array(
+            'whitespace' => '',
+            'operator' => '',
+            'date' => 'tag',
+        ));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/vibrant-ink.css b/inc/hyperlight/vibrant-ink.css
new file mode 100644 (file)
index 0000000..aa2222d
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2008 Konrad Rudolph
+ * All rights reserved.
+ *
+ * Colour scheme based on the Vibrant Ink scheme by Justin Palmer for the
+ * TextMate text editor.
+ * http://alternateidea.com/blog/articles/2006/1/3/textmate-vibrant-ink-theme-and-prototype-bundle
+ */
+
+.source-code.vibrant-ink {
+    background: black;
+    color: white;
+}
+
+.source-code.vibrant-ink .keyword { color: #F60; font-weight: bold; }
+.source-code.vibrant-ink .keyword.literal { color: #FC0; }
+.source-code.vibrant-ink .keyword.type { color: #FC0; }
+.source-code.vibrant-ink .keyword.builtin { color: #44B4CC; }
+.source-code.vibrant-ink .preprocessor { color: #996; }
+.source-code.vibrant-ink .comment { color: #93C; }
+.source-code.vibrant-ink .comment .doc { color: #399; font-weight: bold; }
+.source-code.vibrant-ink .identifier { color: white; }
+.source-code.vibrant-ink .string, .source-code.vibrant-ink .char { color: #6F0; }
+.source-code.vibrant-ink .escaped { color: #AAA; }
+.source-code.vibrant-ink .number, .source-code.vibrant-ink .tag { color: #FFEE98; }
+.source-code.vibrant-ink .regex, .source-code.vibrant-ink .attribute { color: #44B4CC; }
+.source-code.vibrant-ink .operator { color: #888; }
+.source-code.vibrant-ink .keyword.operator { color: #F60; }
+.source-code.vibrant-ink .whitespace { background: #333; }
+.source-code.vibrant-ink .error { border-bottom: 1px solid red; }
+
+.source-code.vibrant-ink .tag .attribute { font-style: italic; }
+.source-code.vibrant-ink.xml .preprocessor .keyword { color #996; }
+.source-code.vibrant-ink.xml .preprocessor .keyword { color: #996; }
+.source-code.vibrant-ink.xml .meta, .source-code.vibrant-ink.xml .meta .keyword { color: #399; }
+
+.source-code.vibrant-ink.cpp .preprocessor .identifier { color: #996; }
+
+.source-code.vibrant-ink::-moz-selection,
+.source-code.vibrant-ink span::-moz-selection {
+    background: yellow;
+    color: black;
+}
+
+.source-code.vibrant-ink::selection,
+.source-code.vibrant-ink span::selection {
+    background: yellow;
+    color: black;
+}
+
diff --git a/inc/hyperlight/wezterm.css b/inc/hyperlight/wezterm.css
new file mode 100644 (file)
index 0000000..f0e1af1
--- /dev/null
@@ -0,0 +1,90 @@
+/* For licensing and copyright terms, see the file named LICENSE */
+/* A colour scheme based on Wez Furlong's terminal and vim preferences */
+
+.source-code.wezterm {
+       background: black;
+       color: rgb(179,179,179);
+}
+
+.source-code.wezterm .php {
+       /* tomato */
+       color: rgb(255, 85, 85);
+}
+
+.source-code.wezterm .comment {
+       /* cyan */
+       color: rgb(85, 255, 255);
+}
+
+.source-code.wezterm .comment .todo {
+       /* yellow background */
+       background-color: rgb(255, 255, 85);
+       color: black;
+}
+
+.source-code.wezterm .tag {
+       /* cyan */
+       color: rgb(85, 255, 255);
+}
+.source-code.wezterm .php .tag {
+       /* yellow */
+       color: rgb(255, 255, 85);
+}
+
+.source-code.wezterm .identifier {
+       /* regular grey text */
+       color: rgb(179,179,179);
+}
+
+.source-code.wezterm .tag .identifier {
+       /* cyan */
+       color: rgb(85, 255, 255);
+}
+
+.source-code.wezterm .keyword {
+       /* yellow */
+       color: rgb(255, 255, 85);
+}
+
+.source-code.wezterm .preprocessor,
+.source-code.wezterm .preprocessor .identifier,
+.source-code.wezterm .keyword.operator,
+.source-code.wezterm .keyword.builtin {
+       /* blue */
+       color: rgb(85, 85, 204);
+}
+.source-code.wezterm .attribute,
+.source-code.wezterm .keyword.type {
+       /* green */
+       color: rgb(85, 204, 85);
+}
+.source-code.wezterm .number,
+.source-code.wezterm .keyword.literal,
+.source-code.wezterm .string
+{
+       /* purple */
+       color: rgb(255, 85, 255);
+}
+
+.source-code.wezterm::-moz-selection,
+.source-code.wezterm span::-moz-selection
+{
+       background-color: rgb(77, 77, 255);
+}
+
+.source-code.wezterm::selection,
+.source-code.wezterm span::selection
+{
+       background-color: rgb(77, 77, 255);
+}
+
+.source-code.wezterm .preprocessor .tag,
+.source-code.wezterm .preprocessor .number {
+       /* purple like a string.
+        * this triggers for things like: #include "name"
+        */
+       color: rgb(255, 85, 255);
+}
+/* vim:ts=2:sw=2:noet:
+ * */
+
diff --git a/inc/hyperlight/wiki.php b/inc/hyperlight/wiki.php
new file mode 100644 (file)
index 0000000..fc80330
--- /dev/null
@@ -0,0 +1,38 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class WikiLanguage extends HyperLanguage {
+  public function __construct() {
+    $this->setInfo(array(
+          parent::NAME => 'Wiki',
+          ));
+    $this->setExtensions(array('wiki'));
+    $this->setCaseInsensitive(false);
+    $this->addStates(array(
+          'init' => array(
+            'bold',
+            'macro',
+            'link',
+            'replink',
+            'keyword' => array('operator'),
+            ),
+          ));
+
+    $this->addRules(array(
+          'bold' => "/'''(?:\\\\'|.)*?'''/s",
+          'macro' => "/\[\[.*\]\]/s",
+          'link' => "/\[[a-z]+:.*\]/Us",
+          'replink' => "/\{[^}]+\}/Us",
+          'keyword' => array(
+            'operator' => '/=+/',
+            ),
+          ));
+        $this->addMappings(array(
+            'bold' => 'string',
+            'link' => 'tag',
+            'replink' => 'tag',
+            'macro' => 'tag',
+        ));
+
+  }
+}
diff --git a/inc/hyperlight/xml.php b/inc/hyperlight/xml.php
new file mode 100644 (file)
index 0000000..95cba7b
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+class XmlLanguage extends HyperLanguage {
+    public function __construct() {
+        $this->setInfo(array(
+            parent::NAME => 'XML',
+            parent::VERSION => '0.3',
+            parent::AUTHOR => array(
+                parent::NAME => 'Konrad Rudolph',
+                parent::WEBSITE => 'madrat.net',
+                parent::EMAIL => 'konrad_rudolph@madrat.net'
+            )
+        ));
+
+        $this->setExtensions(array('xml', 'xsl', 'xslt', 'xsd', 'manifest'));
+
+        $inline = array('entity');
+        $common = array('tagname', 'attribute', 'value' => array('double', 'single'));
+
+        $this->addStates(array(
+            'init' => array_merge(array('comment', 'cdata', 'tag'), $inline),
+            'tag' => array_merge(array('preprocessor', 'meta'), $common),
+            'preprocessor' => $common,
+            'meta' => $common,
+            'value double' => $inline,
+            'value single' => $inline,
+        ));
+        
+        $this->addRules(array(
+            'comment' => '/<!--.*?-->/s',
+            'cdata' => '/<!\[CDATA\[.*?\]\]>/',
+            'tag' => new Rule('/</', '/>/'),
+            'tagname' => '#(?:(?<=<)|(?<=</)|(?<=<\?)|(?<=<!))[a-z0-9:-]+#i',
+            'attribute' => '/[a-z0-9:-]+/i',
+            'preprocessor' => new Rule('/\?/'),
+            'meta' => new Rule('/!/'),
+            'value' => array(
+                'double' => new Rule('/"/', '/"/'),
+                'single' => new Rule("/'/", "/'/")
+            ),
+            'entity' => '/&.*?;/',
+        ));
+
+        $this->addMappings(array(
+            'attribute' => 'keyword type',
+            'cdata' => '',
+            'value double' => 'string',
+            'value single' => 'string',
+            'entity' => 'escaped',
+            'tagname' => 'keyword'
+        ));
+    }
+}
+
+?>
diff --git a/inc/hyperlight/zenburn.css b/inc/hyperlight/zenburn.css
new file mode 100644 (file)
index 0000000..2f51d84
--- /dev/null
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2008 Konrad Rudolph
+ * All rights reserved.
+ *
+ * Color scheme for code is a simplified version of the VIM Zenburn scheme:
+ * http://slinky.imukuppi.org/zenburn/
+ */
+
+.source-code.zenburn {
+    background: #3F3F3F;
+    color: #DCDCCC;
+}
+
+.source-code.zenburn .comment {
+    color: #7F9F7F;
+    font-style: italic;
+}
+
+.source-code.zenburn .comment .todo {
+    color: #DFDFDF;
+    font-weight: bold;
+}
+
+.source-code.zenburn .tag {
+    color: #EFEF8F;
+}
+
+.source-code.zenburn .identifier {
+    color: #EFDCBC;
+}
+
+.source-code.zenburn .keyword {
+    color: #F0DFAF;
+    font-weight: bold;
+}
+
+.source-code.zenburn .keyword.builtin {
+    color: #EFEF8F;
+    font-weight: normal;
+}
+
+.source-code.zenburn .keyword.operator {
+    color: #FFCFAF;
+}
+
+.source-code.zenburn .number {
+    color: #8CD0D3;
+}
+
+.source-code.zenburn .string {
+    color: #CC9393;
+}
+
+.source-code.zenburn::-moz-selection,
+.source-code.zenburn span::-moz-selection {
+    background: #70D2B3;
+    color: #233322;
+}
+
+.source-code.zenburn::selection,
+.source-code.zenburn span::selection {
+    background: yellow;
+    color: black;
+}
diff --git a/inc/issue.php b/inc/issue.php
new file mode 100644 (file)
index 0000000..462da71
--- /dev/null
@@ -0,0 +1,847 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackEnumeration {
+  public $tablename;
+  protected $fieldname;
+  protected $fieldvalue;
+
+  public $name = null;
+  public $value = null;
+  public $deleted = null;
+  public $new = true;
+
+  function enumerate($all = false) {
+    $res = array();
+    if ($all) {
+      foreach (MTrackDB::q(sprintf("select %s, %s, deleted from %s order by %s",
+            $this->fieldname, $this->fieldvalue, $this->tablename,
+            $this->fieldvalue))
+            ->fetchAll(PDO::FETCH_NUM)
+          as $row) {
+        $res[$row[0]] = array(
+          'name' => $row[0],
+          'value' => $row[1],
+          'deleted' => $row[2] == '1' ? true : false
+        );
+      }
+    } else {
+      foreach (MTrackDB::q(sprintf("select %s from %s where deleted != '1'",
+              $this->fieldname, $this->tablename))->fetchAll(PDO::FETCH_NUM)
+          as $row) {
+        $res[$row[0]] = $row[0];
+      }
+    }
+    return $res;
+  }
+
+  function __construct($name = null) {
+    if ($name !== null) {
+      list($row) = MTrackDB::q(sprintf(
+          "select %s, deleted from %s where %s = ?",
+          $this->fieldvalue, $this->tablename, $this->fieldname),
+          $name)
+          ->fetchAll();
+      if (isset($row[0])) {
+        $this->name = $name;
+        $this->value = $row[0];
+        $this->deleted = $row[1];
+        $this->new = false;
+        return;
+      }
+      throw new Exception("unable to find $this->tablename with name = $name");
+    }
+
+    $this->deleted = false;
+  }
+
+  function save(MTrackChangeset $CS) {
+    if ($this->new) {
+      MTrackDB::q(sprintf('insert into %s (%s, %s, deleted) values (?, ?, ?)',
+        $this->tablename, $this->fieldname, $this->fieldvalue),
+            $this->name, $this->value, (int)$this->deleted);
+      $old = null;
+    } else {
+      list($row) = MTrackDB::q(
+        sprintf('select %s, deleted from %s where %s = ?',
+          $this->fieldname, $this->tablename, $this->fieldvalue),
+          $this->name)->fetchAll();
+      $old = $row[0];
+      MTrackDB::q(sprintf('update %s set %s = ?, deleted = ? where %s = ?',
+        $this->tablename, $this->fieldvalue, $this->fieldname),
+            $this->value, (int)$this->deleted, $this->name);
+    }
+    $CS->add($this->tablename . ":" . $this->name . ":" . $this->fieldvalue,
+      $old, $this->value);
+
+  }
+}
+
+class MTrackTicketState extends MTrackEnumeration {
+  public $tablename = 'ticketstates';
+  protected $fieldname = 'statename';
+  protected $fieldvalue = 'ordinal';
+
+  static function loadByName($name) {
+    return new MTrackTicketState($name);
+  }
+}
+
+
+class MTrackPriority extends MTrackEnumeration {
+  public $tablename = 'priorities';
+  protected $fieldname = 'priorityname';
+  protected $fieldvalue = 'value';
+
+  static function loadByName($name) {
+    return new MTrackPriority($name);
+  }
+}
+
+class MTrackSeverity extends MTrackEnumeration {
+  public $tablename = 'severities';
+  protected $fieldname = 'sevname';
+  protected $fieldvalue = 'ordinal';
+
+  static function loadByName($name) {
+    return new MTrackSeverity($name);
+  }
+}
+
+class MTrackResolution extends MTrackEnumeration {
+  public $tablename = 'resolutions';
+  protected $fieldname = 'resname';
+  protected $fieldvalue = 'ordinal';
+
+  static function loadByName($name) {
+    return new MTrackResolution($name);
+  }
+}
+
+class MTrackClassification extends MTrackEnumeration {
+  public $tablename = 'classifications';
+  protected $fieldname = 'classname';
+  protected $fieldvalue = 'ordinal';
+
+  static function loadByName($name) {
+    return new MTrackClassification($name);
+  }
+}
+
+class MTrackComponent {
+  public $compid = null;
+  public $name = null;
+  public $deleted = null;
+  protected $projects = null;
+  protected $origprojects = null;
+
+  static function loadById($id) {
+    return new MTrackComponent($id);
+  }
+
+  static function loadByName($name) {
+    $rows = MTrackDB::q('select compid from components where name = ?',
+      $name)->fetchAll(PDO::FETCH_COLUMN, 0);
+    if (isset($rows[0])) {
+      return self::loadById($rows[0]);
+    }
+    return null;
+  }
+
+  function __construct($id = null) {
+    if ($id !== null) {
+      list($row) = MTrackDB::q(
+                    'select name, deleted from components where compid = ?',
+                    $id)->fetchAll();
+      if (isset($row[0])) {
+        $this->compid = $id;
+        $this->name = $row[0];
+        $this->deleted = $row[1];
+        return;
+      }
+      throw new Exception("unable to find component with id = $id");
+    }
+    $this->deleted = false;
+  }
+
+  function getProjects() {
+    if ($this->origprojects === null) {
+      $this->origprojects = array();
+      foreach (MTrackDB::q('select projid from components_by_project where compid = ? order by projid', $this->compid) as $row) {
+        $this->origprojects[] = $row[0];
+      }
+      $this->projects = $this->origprojects;
+    }
+    return $this->projects;
+  }
+
+  function setProjects($projlist) {
+    $this->projects = $projlist;
+  }
+
+  function save(MTrackChangeset $CS) {
+    if ($this->compid) {
+      list($row) = MTrackDB::q(
+                    'select name, deleted from components where compid = ?',
+                    $id)->fetchAll();
+      $old = $row;
+      MTrackDB::q(
+          'update components set name = ?, deleted = ? where compid = ?',
+          $this->name, (int)$this->deleted, $this->compid);
+    } else {
+      MTrackDB::q('insert into components (name, deleted) values (?, ?)',
+        $this->name, (int)$this->deleted);
+      $this->compid = MTrackDB::lastInsertId('components', 'compid');
+      $old = null;
+    }
+    $CS->add("component:" . $this->compid . ":name", $old['name'], $this->name);
+    $CS->add("component:" . $this->compid . ":deleted", $old['deleted'], $this->deleted);
+    if ($this->projects !== $this->origprojects) {
+      $old = is_array($this->origprojects) ?
+              join(",", $this->origprojects) : '';
+      $new = is_array($this->projects) ?
+              join(",", $this->projects) : '';
+      MTrackDB::q('delete from components_by_project where compid = ?',
+          $this->compid);
+      if (is_array($this->projects)) {
+        foreach ($this->projects as $pid) {
+          MTrackDB::q(
+            'insert into components_by_project (compid, projid) values (?, ?)',
+            $this->compid, $pid);
+        }
+      }
+      $CS->add("component:$this->compid:projects", $old, $new);
+    }
+  }
+}
+
+class MTrackProject {
+  public $projid = null;
+  public $ordinal = 5;
+  public $name = null;
+  public $shortname = null;
+  public $notifyemail = null;
+
+  static function loadById($id) {
+    return new MTrackProject($id);
+  }
+
+  static function loadByName($name) {
+    list($row) = MTrackDB::q('select projid from projects where shortname = ?',
+      $name)->fetchAll();
+    if (isset($row[0])) {
+      return self::loadById($row[0]);
+    }
+    return null;
+  }
+
+  function __construct($id = null) {
+    if ($id !== null) {
+      list($row) = MTrackDB::q(
+                    'select * from projects where projid = ?',
+                    $id)->fetchAll();
+      if (isset($row[0])) {
+        $this->projid = $row['projid'];
+        $this->ordinal = $row['ordinal'];
+        $this->name = $row['name'];
+        $this->shortname = $row['shortname'];
+        $this->notifyemail = $row['notifyemail'];
+        return;
+      }
+      throw new Exception("unable to find project with id = $id");
+    }
+  }
+
+  function save(MTrackChangeset $CS) {
+    if ($this->projid) {
+      list($row) = MTrackDB::q(
+                    'select * from projects where projid = ?',
+                    $this->projid)->fetchAll();
+      $old = $row;
+      MTrackDB::q(
+          'update projects set ordinal = ?, name = ?, shortname = ?,
+            notifyemail = ? where projid = ?',
+          $this->ordinal, $this->name, $this->shortname,
+          $this->notifyemail, $this->projid);
+    } else {
+      MTrackDB::q('insert into projects (ordinal, name,
+          shortname, notifyemail) values (?, ?, ?, ?)',
+        $this->ordinal, $this->name, $this->shortname,
+        $this->notifyemail);
+      $this->projid = MTrackDB::lastInsertId('projects', 'projid');
+      $old = null;
+    }
+    $CS->add("project:" . $this->projid . ":name", $old['name'], $this->name);
+    $CS->add("project:" . $this->projid . ":ordinal", $old['ordinal'], $this->ordinal);
+    $CS->add("project:" . $this->projid . ":shortname", $old['shortname'], $this->shortname);
+    $CS->add("project:" . $this->projid . ":notifyemail", $old['notifyemail'], $this->notifyemail);
+  }
+
+  function _adjust_ticket_link($M) {
+    $tktlimit = MTrackConfig::get('trac_import', "max_ticket:$this->shortname");
+    if ($M[1] <= $tktlimit) {
+      return "#$this->shortname$M[1]";
+    }
+    return $M[0];
+  }
+
+  function adjust_links($reason, $use_ticket_prefix)
+  {
+    if (!$use_ticket_prefix) {
+      return $reason;
+    }
+
+    $tktlimit = MTrackConfig::get('trac_import', "max_ticket:$this->shortname");
+    if ($tktlimit !== null) {
+      $reason = preg_replace_callback('/#(\d+)/',
+        array($this, '_adjust_ticket_link'), $reason);
+    } else {
+//      don't do this if the number is outside the valid ranges
+//      may need to be clever about this during trac imports
+//      $reason = preg_replace('/#(\d+)/', "#$this->shortname\$1", $reason);
+    }
+// FIXME: this and the above need to be more intelligent
+    $reason = preg_replace('/\[(\d+)\]/', "[$this->shortname\$1]", $reason);
+    return $reason;
+  }
+}
+
+/* The listener protocol is to return true if all is good,
+ * or to return either a string or an array of strings that
+ * detail why a change is not allowed to proceed */
+interface IMTrackIssueListener {
+  function vetoMilestone(MTrackIssue $issue,
+            MTrackMilestone $ms, $assoc = true);
+  function vetoKeyword(MTrackIssue $issue,
+            MTrackKeyword $kw, $assoc = true);
+  function vetoComponent(MTrackIssue $issue,
+            MTrackComponent $comp, $assoc = true);
+  function vetoProject(MTrackIssue $issue,
+            MTrackProject $proj, $assoc = true);
+  function vetoComment(MTrackIssue $issue, $comment);
+  function vetoSave(MTrackIssue $issue, $oldFields);
+
+  function augmentFormFields(MTrackIssue $issue, &$fieldset);
+  function applyPOSTData(MTrackIssue $issue, $data);
+  function augmentSaveParams(MTrackIssue $issue, &$params);
+  function augmentIndexerFields(MTrackIssue $issue, &$idx);
+}
+
+class MTrackVetoException extends Exception {
+  public $reasons;
+
+  function __construct($reasons) {
+    $this->reasons = $reasons;
+    parent::__construct(join("\n", $reasons));
+  }
+}
+
+class MTrackIssue {
+  public $tid = null;
+  public $nsident = null;
+  public $summary = null;
+  public $description = null;
+  public $created = null;
+  public $updated = null;
+  public $owner = null;
+  public $priority = null;
+  public $severity = null;
+  public $classification = null;
+  public $resolution = null;
+  public $status = null;
+  public $estimated = null;
+  public $spent = null;
+  public $changelog = null;
+  public $cc = null;
+  protected $components = null;
+  protected $origcomponents = null;
+  protected $milestones = null;
+  protected $origmilestones = null;
+  protected $comments_to_add = array();
+  protected $keywords = null;
+  protected $origkeywords = null;
+  protected $effort = array();
+
+  static $_listeners = array();
+
+  static function loadById($id) {
+    try {
+      return new MTrackIssue($id);
+    } catch (Exception $e) {
+    }
+    return null;
+  }
+
+  static function loadByNSIdent($id) {
+    static $cache = array();
+    if (!isset($cache[$id])) {
+      $ids = MTrackDB::q('select tid from tickets where nsident = ?', $id)
+            ->fetchAll(PDO::FETCH_COLUMN, 0);
+      if (count($ids) == 1) {
+        $cache[$id] = $ids[0];
+      } else {
+        return null;
+      }
+    }
+    return new MTrackIssue($cache[$id]);
+  }
+
+  static function registerListener(IMTrackIssueListener $l)
+  {
+    self::$_listeners[] = $l;
+  }
+
+  function __construct($tid = null) {
+    if ($tid === null) {
+      $this->components = array();
+      $this->origcomponents = array();
+      $this->milestones = array();
+      $this->origmilestones = array();
+      $this->keywords = array();
+      $this->origkeywords = array();
+      $this->status = 'new';
+
+      foreach (array('classification', 'severity', 'priority') as $f) {
+        $this->$f = MTrackConfig::get('ticket', "default.$f");
+      }
+    } else {
+      $data =  MTrackDB::q('select * from tickets where tid = ?',
+                        $tid)->fetchAll();
+      if (isset($data[0])) {
+        $row = $data[0];
+      } else {
+        $row = null;
+      }
+      if (!is_array($row)) {
+        throw new Exception("no such issue $tid");
+      }
+      foreach ($row as $k => $v) {
+        $this->$k = $v;
+      }
+    }
+  }
+
+  function applyPOSTData($data) {
+    foreach (self::$_listeners as $l) {
+      $l->applyPOSTData($this, $data);
+    }
+  }
+
+  function augmentFormFields(&$FIELDSET) {
+    foreach (self::$_listeners as $l) {
+      $l->augmentFormFields($this, $FIELDSET);
+    }
+  }
+  function augmentIndexerFields(&$idx) {
+    foreach (self::$_listeners as $l) {
+      $l->augmentIndexerFields($this, $idx);
+    }
+  }
+  function augmentSaveParams(&$params) {
+    foreach (self::$_listeners as $l) {
+      $l->augmentSaveParams($this, $params);
+    }
+  }
+
+  function checkVeto()
+  {
+    $args = func_get_args();
+    $method = array_shift($args);
+    $veto = array();
+
+    foreach (self::$_listeners as $l) {
+      $v = call_user_func_array(array($l, $method), $args);
+      if ($v !== true) {
+        $veto[] = $v;
+      }
+    }
+    if (count($veto)) {
+      $reasons = array();
+      foreach ($veto as $r) {
+        if (is_array($r)) {
+          foreach ($r as $m) {
+            $reasons[] = $m;
+          }
+        } else {
+          $reasons[] = $r;
+        }
+      }
+      throw new MTrackVetoException($reasons);
+    }
+  }
+
+  function save(MTrackChangeset $CS)
+  {
+    $db = MTrackDB::get();
+    $reindex = false;
+
+    if ($this->tid === null) {
+      $this->created = $CS->cid;
+      $oldrow = array();
+      $reindex = true;
+    } else {
+      list($oldrow) = MTrackDB::q('select * from tickets where tid = ?',
+                        $this->tid)->fetchAll();
+    }
+
+    $this->checkVeto('vetoSave', $this, $oldrow);
+
+    $this->updated = $CS->cid;
+
+    $params = array(
+      'summary' => $this->summary,
+      'description' => $this->description,
+      'created' => $this->created,
+      'updated' => $this->updated,
+      'owner' => $this->owner,
+      'changelog' => $this->changelog,
+      'priority' => $this->priority,
+      'severity' => $this->severity,
+      'classification' => $this->classification,
+      'resolution' => $this->resolution,
+      'status' => $this->status,
+      'estimated' => (float)$this->estimated,
+      'spent' => (float)$this->spent,
+      'nsident' => $this->nsident,
+      'cc' => $this->cc,
+    );
+
+    $this->augmentSaveParams($params);
+
+    if ($this->tid === null) {
+      $sql = 'insert into tickets ';
+      $keys = array();
+      $values = array();
+
+      $new_tid = new OmniTI_Util_UUID;
+      $new_tid = $new_tid->toRFC4122String(false);
+
+      $keys[] = "tid";
+      $values[] = "'$new_tid'";
+
+      foreach ($params as $key => $value) {
+        $keys[] = $key;
+        $values[] = ":$key";
+      }
+
+      $sql .= "(" . join(', ', $keys) . ") values (" .
+              join(', ', $values) . ")";
+    } else {
+      $sql = 'update tickets set ';
+      $values = array();
+      foreach ($params as $key => $value) {
+        $values[] = "$key = :$key";
+      }
+      $sql .= join(', ', $values) . " where tid = :tid";
+
+      $params['tid'] = $this->tid;
+    }
+
+    $q = $db->prepare($sql);
+    $q->execute($params);
+
+    if ($this->tid === null) {
+      $this->tid = $new_tid;
+      $created = true;
+    } else {
+      $created = false;
+    }
+
+    foreach ($params as $key => $value) {
+      if ($key == 'created' || $key == 'updated' || $key == 'tid') {
+        continue;
+      }
+      if ($key == 'changelog' || $key == 'description' || $key == 'summary') {
+        if (!isset($oldrow[$key]) || $oldrow[$key] != $value) {
+          $reindex = true;
+        }
+      }
+      if (!isset($oldrow[$key])) {
+        $oldrow[$key] = null;
+      }
+      $CS->add("ticket:$this->tid:$key", $oldrow[$key], $value);
+    }
+
+    $this->compute_diff($CS, 'components', 'ticket_components', 'compid',
+        $this->components, $this->origcomponents);
+    $this->compute_diff($CS, 'keywords', 'ticket_keywords', 'kid',
+        $this->keywords, $this->origkeywords);
+    $this->compute_diff($CS, 'milestones', 'ticket_milestones', 'mid',
+        $this->milestones, $this->origmilestones);
+
+    foreach ($this->comments_to_add as $text) {
+      $CS->add("ticket:$this->tid:@comment", null, $text);
+    }
+
+    foreach ($this->effort as $effort) {
+      MTrackDB::q('insert into effort (tid, cid, expended, remaining)
+        values (?, ?, ?, ?)',
+        $this->tid, $CS->cid, $effort[0], $effort[1]);
+    }
+    $this->effort = array();
+  }
+
+  static function index_issue($object)
+  {
+    list($ignore, $ident) = explode(':', $object, 2);
+    $i = MTrackIssue::loadById($ident);
+    if (!$i) return;
+    echo "Ticket #$i->nsident\n";
+
+    $CS = MTrackChangeset::get($i->updated);
+    $CSC = MTrackChangeset::get($i->created);
+
+    $kw = join(' ', array_values($i->getKeywords()));
+    $idx = array(
+            'summary' => $i->summary,
+            'description' => $i->description,
+            'changelog' => $i->changelog,
+            'keyword' => $kw,
+            'stored:date' => $CS->when,
+            'who' => $CS->who,
+            'creator' => $CSC->who,
+            'stored:created' => $CSC->when,
+            'owner' => $i->owner
+            );
+    $i->augmentIndexerFields($idx);
+    MTrackSearchDB::add("ticket:$i->tid", $idx, true);
+
+    foreach (MTrackDB::q('select value, changedate, who from
+        change_audit left join changes using (cid) where fieldname = ?',
+        "ticket:$ident:@comment") as $row) {
+      list($text, $when, $who) = $row;
+      $start = time();
+      $id = sha1($text);
+      $elapsed = time() - $start;
+      if ($elapsed > 4) {
+        echo "  - comment $who $when took $elapsed to hash\n";
+      }
+      $start = time();
+      if (strlen($text) > 8192) {
+        // A huge paste into a ticket
+        $text = substr($text, 0, 8192);
+      }
+      MTrackSearchDB::add("ticket:$ident:comment:$id", array(
+        'description' => $text,
+        'stored:date' => $when,
+        'who' => $who,
+      ), true);
+
+      $elapsed = time() - $start;
+      if ($elapsed > 4) {
+        echo "  - comment $who $when took $elapsed to index\n";
+      }
+    }
+  }
+
+  private function compute_diff(MTrackChangeset $CS, $label,
+        $tablename, $keyname, $current, $orig) {
+    if (!is_array($current)) {
+      $current = array();
+    }
+    if (!is_array($orig)) {
+      $orig = array();
+    }
+    $added = array_keys(array_diff_key($current, $orig));
+    $removed = array_keys(array_diff_key($orig, $current));
+
+    $db = MTrackDB::get();
+    $ADD = $db->prepare(
+      "insert into $tablename (tid, $keyname) values (?, ?)");
+    $DEL = $db->prepare(
+      "delete from $tablename where tid = ? AND $keyname = ?");
+    foreach ($added as $key) {
+      $ADD->execute(array($this->tid, $key));
+    }
+    foreach ($removed as $key) {
+      $DEL->execute(array($this->tid, $key));
+    }
+    if (count($added) + count($removed)) {
+      $old = join(',', array_keys($orig));
+      $new = join(',', array_keys($current));
+      $CS->add(
+        "ticket:$this->tid:@$label", $old, $new);
+    }
+  }
+  function getComponents()
+  {
+    if ($this->components === null) {
+      $comps = MTrackDB::q('select tc.compid, name from ticket_components tc left join components using (compid) where tid = ?', $this->tid)->fetchAll();
+      $this->origcomponents = array();
+      foreach ($comps as $row) {
+        $this->origcomponents[$row[0]] = $row[1];
+      }
+      $this->components = $this->origcomponents;
+    }
+    return $this->components;
+  }
+
+  private function resolveComponent($comp)
+  {
+    if ($comp instanceof MTrackComponent) {
+      return $comp;
+    }
+    if (ctype_digit($comp)) {
+      return MTrackComponent::loadById($comp);
+    }
+    return MTrackComponent::loadByName($comp);
+  }
+
+  function assocComponent($comp)
+  {
+    $comp = $this->resolveComponent($comp);
+    $this->getComponents();
+    $this->checkVeto('vetoComponent', $this, $comp, true);
+    $this->components[$comp->compid] = $comp->name;
+  }
+
+  function dissocComponent($comp)
+  {
+    $comp = $this->resolveComponent($comp);
+    $this->getComponents();
+    $this->checkVeto('vetoComponent', $this, $comp, false);
+    unset($this->components[$comp->compid]);
+  }
+
+  function getMilestones()
+  {
+    if ($this->milestones === null) {
+      $comps = MTrackDB::q('select tc.mid, name from ticket_milestones tc left join milestones using (mid) where tid = ? order by duedate, name', $this->tid)->fetchAll();
+      $this->origmilestones = array();
+      foreach ($comps as $row) {
+        $this->origmilestones[$row[0]] = $row[1];
+      }
+      $this->milestones = $this->origmilestones;
+    }
+    return $this->milestones;
+  }
+
+  private function resolveMilestone($ms)
+  {
+    if ($ms instanceof MTrackMilestone) {
+      return $ms;
+    }
+    if (ctype_digit($ms)) {
+      return MTrackMilestone::loadById($ms);
+    }
+    return MTrackMilestone::loadByName($ms);
+  }
+
+  function assocMilestone($M)
+  {
+    $ms = $this->resolveMilestone($M);
+    if ($ms === null) {
+      throw new Exception("unable to resolve milestone $M");
+    }
+    $this->getMilestones();
+    $this->checkVeto('vetoMilestone', $this, $ms, true);
+    $this->milestones[$ms->mid] = $ms->name;
+  }
+
+  function dissocMilestone($M)
+  {
+    $ms = $this->resolveMilestone($M);
+    if ($ms === null) {
+      throw new Exception("unable to resolve milestone $M");
+    }
+    $this->getMilestones();
+    $this->checkVeto('vetoMilestone', $this, $ms, false);
+    unset($this->milestones[$ms->mid]);
+  }
+
+  function addComment($comment)
+  {
+    $comment = trim($comment);
+    if (strlen($comment)) {
+      $this->checkVeto('vetoComment', $this, $comment);
+      $this->comments_to_add[] = $comment;
+    }
+  }
+
+  private function resolveKeyword($kw)
+  {
+    if ($kw instanceof MTrackKeyword) {
+      return $kw;
+    }
+    $k = MTrackKeyword::loadByWord($kw);
+    if ($k === null) {
+      if (ctype_digit($kw)) {
+        return MTrackKeyword::loadById($kw);
+      }
+      throw new Exception("unknown keyword $kw");
+    }
+    return $k;
+  }
+
+  function assocKeyword($kw)
+  {
+    $kw = $this->resolveKeyword($kw);
+    $this->getKeywords();
+    $this->checkVeto('vetoKeyword', $this, $kw, true);
+    $this->keywords[$kw->kid] = $kw->keyword;
+  }
+
+  function dissocKeyword($kw)
+  {
+    $kw = $this->resolveKeyword($kw);
+    $this->getKeywords();
+    $this->checkVeto('vetoKeyword', $this, $kw, false);
+    unset($this->keywords[$kw->kid]);
+  }
+
+  function getKeywords()
+  {
+    if ($this->keywords === null) {
+      $comps = MTrackDB::q('select tc.kid, keyword from ticket_keywords tc left join keywords using (kid) where tid = ?', $this->tid)->fetchAll();
+      $this->origkeywords = array();
+      foreach ($comps as $row) {
+        $this->origkeywords[$row[0]] = $row[1];
+      }
+      $this->keywords = $this->origkeywords;
+    }
+    return $this->keywords;
+  }
+
+  function addEffort($amount, $revised = null)
+  {
+    $diff = null;
+    if ($revised !== null) {
+      $diff = $revised - $this->estimated;
+      $this->estimated = $revised;
+    }
+    $this->effort[] = array($amount, $diff);
+    $this->spent += $amount;
+  }
+
+  function close()
+  {
+    $this->status = 'closed';
+    $this->addEffort(0, 0);
+  }
+
+  function isOpen()
+  {
+    switch ($this->status) {
+      case 'closed':
+        return false;
+      default:
+        return true;
+    }
+  }
+
+  function reOpen()
+  {
+    $this->status = 'reopened';
+    $this->resolution = null;
+  }
+
+}
+
+MTrackSearchDB::register_indexer('ticket', array('MTrackIssue', 'index_issue'));
+MTrackACL::registerAncestry('enum', 'Enumerations');
+MTrackACL::registerAncestry("component", 'Components');
+MTrackACL::registerAncestry("project", 'Projects');
+MTrackACL::registerAncestry("ticket", "Tickets");
+MTrackWatch::registerEventTypes('ticket', array(
+  'ticket' => 'Tickets'
+));
diff --git a/inc/keywords.php b/inc/keywords.php
new file mode 100644 (file)
index 0000000..1d08b6d
--- /dev/null
@@ -0,0 +1,39 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackKeyword {
+  public $kid;
+  public $keyword;
+
+  static function loadByWord($word)
+  {
+    foreach (MTrackDB::q('select kid from keywords where keyword = ?', $word)
+        ->fetchAll() as $row) {
+      return new MTrackKeyword($row[0]);
+    }
+    return null;
+  }
+
+  function __construct($id = null)
+  {
+    if ($id !== null) {
+      list($row) = MTrackDB::q('select keyword from keywords where kid = ?',
+          $id)->fetchAll();
+      $this->kid = $id;
+      $this->keyword = $row[0];
+      return;
+    }
+  }
+
+  function save(MTrackChangeset $CS)
+  {
+    if ($this->kid === null) {
+      MTrackDB::q('insert into keywords (keyword) values (?)', $this->keyword);
+      $this->kid = MTrackDB::lastInsertId('keywords', 'kid');
+      $CS->add("keywords:keyword", null, $this->keyword);
+    } else {
+      throw new Exception("not allowed to rename keywords");
+    }
+  }
+}
+
diff --git a/inc/lib/Auth/COPYING b/inc/lib/Auth/COPYING
new file mode 100644 (file)
index 0000000..d645695
--- /dev/null
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/inc/lib/Auth/OpenID.php b/inc/lib/Auth/OpenID.php
new file mode 100644 (file)
index 0000000..6556b5b
--- /dev/null
@@ -0,0 +1,552 @@
+<?php
+
+/**
+ * This is the PHP OpenID library by JanRain, Inc.
+ *
+ * This module contains core utility functionality used by the
+ * library.  See Consumer.php and Server.php for the consumer and
+ * server implementations.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * The library version string
+ */
+define('Auth_OpenID_VERSION', '2.1.2');
+
+/**
+ * Require the fetcher code.
+ */
+require_once "Auth/Yadis/PlainHTTPFetcher.php";
+require_once "Auth/Yadis/ParanoidHTTPFetcher.php";
+require_once "Auth/OpenID/BigMath.php";
+require_once "Auth/OpenID/URINorm.php";
+
+/**
+ * Status code returned by the server when the only option is to show
+ * an error page, since we do not have enough information to redirect
+ * back to the consumer. The associated value is an error message that
+ * should be displayed on an HTML error page.
+ *
+ * @see Auth_OpenID_Server
+ */
+define('Auth_OpenID_LOCAL_ERROR', 'local_error');
+
+/**
+ * Status code returned when there is an error to return in key-value
+ * form to the consumer. The caller should return a 400 Bad Request
+ * response with content-type text/plain and the value as the body.
+ *
+ * @see Auth_OpenID_Server
+ */
+define('Auth_OpenID_REMOTE_ERROR', 'remote_error');
+
+/**
+ * Status code returned when there is a key-value form OK response to
+ * the consumer. The value associated with this code is the
+ * response. The caller should return a 200 OK response with
+ * content-type text/plain and the value as the body.
+ *
+ * @see Auth_OpenID_Server
+ */
+define('Auth_OpenID_REMOTE_OK', 'remote_ok');
+
+/**
+ * Status code returned when there is a redirect back to the
+ * consumer. The value is the URL to redirect back to. The caller
+ * should return a 302 Found redirect with a Location: header
+ * containing the URL.
+ *
+ * @see Auth_OpenID_Server
+ */
+define('Auth_OpenID_REDIRECT', 'redirect');
+
+/**
+ * Status code returned when the caller needs to authenticate the
+ * user. The associated value is a {@link Auth_OpenID_ServerRequest}
+ * object that can be used to complete the authentication. If the user
+ * has taken some authentication action, use the retry() method of the
+ * {@link Auth_OpenID_ServerRequest} object to complete the request.
+ *
+ * @see Auth_OpenID_Server
+ */
+define('Auth_OpenID_DO_AUTH', 'do_auth');
+
+/**
+ * Status code returned when there were no OpenID arguments
+ * passed. This code indicates that the caller should return a 200 OK
+ * response and display an HTML page that says that this is an OpenID
+ * server endpoint.
+ *
+ * @see Auth_OpenID_Server
+ */
+define('Auth_OpenID_DO_ABOUT', 'do_about');
+
+/**
+ * Defines for regexes and format checking.
+ */
+define('Auth_OpenID_letters',
+       "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
+
+define('Auth_OpenID_digits',
+       "0123456789");
+
+define('Auth_OpenID_punct',
+       "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~");
+
+if (Auth_OpenID_getMathLib() === null) {
+    Auth_OpenID_setNoMathSupport();
+}
+
+/**
+ * The OpenID utility function class.
+ *
+ * @package OpenID
+ * @access private
+ */
+class Auth_OpenID {
+
+    /**
+     * Return true if $thing is an Auth_OpenID_FailureResponse object;
+     * false if not.
+     *
+     * @access private
+     */
+    function isFailure($thing)
+    {
+        return is_a($thing, 'Auth_OpenID_FailureResponse');
+    }
+
+    /**
+     * Gets the query data from the server environment based on the
+     * request method used.  If GET was used, this looks at
+     * $_SERVER['QUERY_STRING'] directly.  If POST was used, this
+     * fetches data from the special php://input file stream.
+     *
+     * Returns an associative array of the query arguments.
+     *
+     * Skips invalid key/value pairs (i.e. keys with no '=value'
+     * portion).
+     *
+     * Returns an empty array if neither GET nor POST was used, or if
+     * POST was used but php://input cannot be opened.
+     *
+     * @access private
+     */
+    function getQuery($query_str=null)
+    {
+        $data = array();
+
+        if ($query_str !== null) {
+            $data = Auth_OpenID::params_from_string($query_str);
+        } else if (!array_key_exists('REQUEST_METHOD', $_SERVER)) {
+            // Do nothing.
+        } else {
+          // XXX HACK FIXME HORRIBLE.
+          //
+          // POSTing to a URL with query parameters is acceptable, but
+          // we don't have a clean way to distinguish those parameters
+          // when we need to do things like return_to verification
+          // which only want to look at one kind of parameter.  We're
+          // going to emulate the behavior of some other environments
+          // by defaulting to GET and overwriting with POST if POST
+          // data is available.
+          $data = Auth_OpenID::params_from_string($_SERVER['QUERY_STRING']);
+
+          if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+            $str = file_get_contents('php://input');
+
+            if ($str === false) {
+              $post = array();
+            } else {
+              $post = Auth_OpenID::params_from_string($str);
+            }
+
+            $data = array_merge($data, $post);
+          }
+        }
+
+        return $data;
+    }
+
+    function params_from_string($str)
+    {
+        $chunks = explode("&", $str);
+
+        $data = array();
+        foreach ($chunks as $chunk) {
+            $parts = explode("=", $chunk, 2);
+
+            if (count($parts) != 2) {
+                continue;
+            }
+
+            list($k, $v) = $parts;
+            $data[$k] = urldecode($v);
+        }
+
+        return $data;
+    }
+
+    /**
+     * Create dir_name as a directory if it does not exist. If it
+     * exists, make sure that it is, in fact, a directory.  Returns
+     * true if the operation succeeded; false if not.
+     *
+     * @access private
+     */
+    function ensureDir($dir_name)
+    {
+        if (is_dir($dir_name) || @mkdir($dir_name)) {
+            return true;
+        } else {
+            $parent_dir = dirname($dir_name);
+
+            // Terminal case; there is no parent directory to create.
+            if ($parent_dir == $dir_name) {
+                return true;
+            }
+
+            return (Auth_OpenID::ensureDir($parent_dir) && @mkdir($dir_name));
+        }
+    }
+
+    /**
+     * Adds a string prefix to all values of an array.  Returns a new
+     * array containing the prefixed values.
+     *
+     * @access private
+     */
+    function addPrefix($values, $prefix)
+    {
+        $new_values = array();
+        foreach ($values as $s) {
+            $new_values[] = $prefix . $s;
+        }
+        return $new_values;
+    }
+
+    /**
+     * Convenience function for getting array values.  Given an array
+     * $arr and a key $key, get the corresponding value from the array
+     * or return $default if the key is absent.
+     *
+     * @access private
+     */
+    function arrayGet($arr, $key, $fallback = null)
+    {
+        if (is_array($arr)) {
+            if (array_key_exists($key, $arr)) {
+                return $arr[$key];
+            } else {
+                return $fallback;
+            }
+        } else {
+            trigger_error("Auth_OpenID::arrayGet (key = ".$key.") expected " .
+                          "array as first parameter, got " .
+                          gettype($arr), E_USER_WARNING);
+
+            return false;
+        }
+    }
+
+    /**
+     * Replacement for PHP's broken parse_str.
+     */
+    function parse_str($query)
+    {
+        if ($query === null) {
+            return null;
+        }
+
+        $parts = explode('&', $query);
+
+        $new_parts = array();
+        for ($i = 0; $i < count($parts); $i++) {
+            $pair = explode('=', $parts[$i]);
+
+            if (count($pair) != 2) {
+                continue;
+            }
+
+            list($key, $value) = $pair;
+            $new_parts[$key] = urldecode($value);
+        }
+
+        return $new_parts;
+    }
+
+    /**
+     * Implements the PHP 5 'http_build_query' functionality.
+     *
+     * @access private
+     * @param array $data Either an array key/value pairs or an array
+     * of arrays, each of which holding two values: a key and a value,
+     * sequentially.
+     * @return string $result The result of url-encoding the key/value
+     * pairs from $data into a URL query string
+     * (e.g. "username=bob&id=56").
+     */
+    function httpBuildQuery($data)
+    {
+        $pairs = array();
+        foreach ($data as $key => $value) {
+            if (is_array($value)) {
+                $pairs[] = urlencode($value[0])."=".urlencode($value[1]);
+            } else {
+                $pairs[] = urlencode($key)."=".urlencode($value);
+            }
+        }
+        return implode("&", $pairs);
+    }
+
+    /**
+     * "Appends" query arguments onto a URL.  The URL may or may not
+     * already have arguments (following a question mark).
+     *
+     * @access private
+     * @param string $url A URL, which may or may not already have
+     * arguments.
+     * @param array $args Either an array key/value pairs or an array of
+     * arrays, each of which holding two values: a key and a value,
+     * sequentially.  If $args is an ordinary key/value array, the
+     * parameters will be added to the URL in sorted alphabetical order;
+     * if $args is an array of arrays, their order will be preserved.
+     * @return string $url The original URL with the new parameters added.
+     *
+     */
+    function appendArgs($url, $args)
+    {
+        if (count($args) == 0) {
+            return $url;
+        }
+
+        // Non-empty array; if it is an array of arrays, use
+        // multisort; otherwise use sort.
+        if (array_key_exists(0, $args) &&
+            is_array($args[0])) {
+            // Do nothing here.
+        } else {
+            $keys = array_keys($args);
+            sort($keys);
+            $new_args = array();
+            foreach ($keys as $key) {
+                $new_args[] = array($key, $args[$key]);
+            }
+            $args = $new_args;
+        }
+
+        $sep = '?';
+        if (strpos($url, '?') !== false) {
+            $sep = '&';
+        }
+
+        return $url . $sep . Auth_OpenID::httpBuildQuery($args);
+    }
+
+    /**
+     * Implements python's urlunparse, which is not available in PHP.
+     * Given the specified components of a URL, this function rebuilds
+     * and returns the URL.
+     *
+     * @access private
+     * @param string $scheme The scheme (e.g. 'http').  Defaults to 'http'.
+     * @param string $host The host.  Required.
+     * @param string $port The port.
+     * @param string $path The path.
+     * @param string $query The query.
+     * @param string $fragment The fragment.
+     * @return string $url The URL resulting from assembling the
+     * specified components.
+     */
+    function urlunparse($scheme, $host, $port = null, $path = '/',
+                        $query = '', $fragment = '')
+    {
+
+        if (!$scheme) {
+            $scheme = 'http';
+        }
+
+        if (!$host) {
+            return false;
+        }
+
+        if (!$path) {
+            $path = '';
+        }
+
+        $result = $scheme . "://" . $host;
+
+        if ($port) {
+            $result .= ":" . $port;
+        }
+
+        $result .= $path;
+
+        if ($query) {
+            $result .= "?" . $query;
+        }
+
+        if ($fragment) {
+            $result .= "#" . $fragment;
+        }
+
+        return $result;
+    }
+
+    /**
+     * Given a URL, this "normalizes" it by adding a trailing slash
+     * and / or a leading http:// scheme where necessary.  Returns
+     * null if the original URL is malformed and cannot be normalized.
+     *
+     * @access private
+     * @param string $url The URL to be normalized.
+     * @return mixed $new_url The URL after normalization, or null if
+     * $url was malformed.
+     */
+    function normalizeUrl($url)
+    {
+        @$parsed = parse_url($url);
+
+        if (!$parsed) {
+            return null;
+        }
+
+        if (isset($parsed['scheme']) &&
+            isset($parsed['host'])) {
+            $scheme = strtolower($parsed['scheme']);
+            if (!in_array($scheme, array('http', 'https'))) {
+                return null;
+            }
+        } else {
+            $url = 'http://' . $url;
+        }
+
+        $normalized = Auth_OpenID_urinorm($url);
+        if ($normalized === null) {
+            return null;
+        }
+        list($defragged, $frag) = Auth_OpenID::urldefrag($normalized);
+        return $defragged;
+    }
+
+    /**
+     * Replacement (wrapper) for PHP's intval() because it's broken.
+     *
+     * @access private
+     */
+    function intval($value)
+    {
+        $re = "/^\\d+$/";
+
+        if (!preg_match($re, $value)) {
+            return false;
+        }
+
+        return intval($value);
+    }
+
+    /**
+     * Count the number of bytes in a string independently of
+     * multibyte support conditions.
+     *
+     * @param string $str The string of bytes to count.
+     * @return int The number of bytes in $str.
+     */
+    function bytes($str)
+    {
+        return strlen(bin2hex($str)) / 2;
+    }
+
+    /**
+     * Get the bytes in a string independently of multibyte support
+     * conditions.
+     */
+    function toBytes($str)
+    {
+        $hex = bin2hex($str);
+
+        if (!$hex) {
+            return array();
+        }
+
+        $b = array();
+        for ($i = 0; $i < strlen($hex); $i += 2) {
+            $b[] = chr(base_convert(substr($hex, $i, 2), 16, 10));
+        }
+
+        return $b;
+    }
+
+    function urldefrag($url)
+    {
+        $parts = explode("#", $url, 2);
+
+        if (count($parts) == 1) {
+            return array($parts[0], "");
+        } else {
+            return $parts;
+        }
+    }
+
+    function filter($callback, &$sequence)
+    {
+        $result = array();
+
+        foreach ($sequence as $item) {
+            if (call_user_func_array($callback, array($item))) {
+                $result[] = $item;
+            }
+        }
+
+        return $result;
+    }
+
+    function update(&$dest, &$src)
+    {
+        foreach ($src as $k => $v) {
+            $dest[$k] = $v;
+        }
+    }
+
+    /**
+     * Wrap PHP's standard error_log functionality.  Use this to
+     * perform all logging. It will interpolate any additional
+     * arguments into the format string before logging.
+     *
+     * @param string $format_string The sprintf format for the message
+     */
+    function log($format_string)
+    {
+        $args = func_get_args();
+        $message = call_user_func_array('sprintf', $args);
+        error_log($message);
+    }
+
+    function autoSubmitHTML($form, $title="OpenId transaction in progress")
+    {
+        return("<html>".
+               "<head><title>".
+               $title .
+               "</title></head>".
+               "<body onload='document.forms[0].submit();'>".
+               $form .
+               "<script>".
+               "var elements = document.forms[0].elements;".
+               "for (var i = 0; i < elements.length; i++) {".
+               "  elements[i].style.display = \"none\";".
+               "}".
+               "</script>".
+               "</body>".
+               "</html>");
+    }
+}
+?>
diff --git a/inc/lib/Auth/OpenID/AX.php b/inc/lib/Auth/OpenID/AX.php
new file mode 100644 (file)
index 0000000..4a617ae
--- /dev/null
@@ -0,0 +1,1023 @@
+<?php
+
+/**
+ * Implements the OpenID attribute exchange specification, version 1.0
+ * as of svn revision 370 from openid.net svn.
+ *
+ * @package OpenID
+ */
+
+/**
+ * Require utility classes and functions for the consumer.
+ */
+require_once "Auth/OpenID/Extension.php";
+require_once "Auth/OpenID/Message.php";
+require_once "Auth/OpenID/TrustRoot.php";
+
+define('Auth_OpenID_AX_NS_URI',
+       'http://openid.net/srv/ax/1.0');
+
+// Use this as the 'count' value for an attribute in a FetchRequest to
+// ask for as many values as the OP can provide.
+define('Auth_OpenID_AX_UNLIMITED_VALUES', 'unlimited');
+
+// Minimum supported alias length in characters.  Here for
+// completeness.
+define('Auth_OpenID_AX_MINIMUM_SUPPORTED_ALIAS_LENGTH', 32);
+
+/**
+ * AX utility class.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX {
+    /**
+     * @param mixed $thing Any object which may be an
+     * Auth_OpenID_AX_Error object.
+     *
+     * @return bool true if $thing is an Auth_OpenID_AX_Error; false
+     * if not.
+     */
+    function isError($thing)
+    {
+        return is_a($thing, 'Auth_OpenID_AX_Error');
+    }
+}
+
+/**
+ * Check an alias for invalid characters; raise AXError if any are
+ * found.  Return None if the alias is valid.
+ */
+function Auth_OpenID_AX_checkAlias($alias)
+{
+  if (strpos($alias, ',') !== false) {
+      return new Auth_OpenID_AX_Error(sprintf(
+                   "Alias %s must not contain comma", $alias));
+  }
+  if (strpos($alias, '.') !== false) {
+      return new Auth_OpenID_AX_Error(sprintf(
+                   "Alias %s must not contain period", $alias));
+  }
+
+  return true;
+}
+
+/**
+ * Results from data that does not meet the attribute exchange 1.0
+ * specification
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_Error {
+    function Auth_OpenID_AX_Error($message=null)
+    {
+        $this->message = $message;
+    }
+}
+
+/**
+ * Abstract class containing common code for attribute exchange
+ * messages.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_Message extends Auth_OpenID_Extension {
+    /**
+     * ns_alias: The preferred namespace alias for attribute exchange
+     * messages
+     */
+    var $ns_alias = 'ax';
+
+    /**
+     * mode: The type of this attribute exchange message. This must be
+     * overridden in subclasses.
+     */
+    var $mode = null;
+
+    var $ns_uri = Auth_OpenID_AX_NS_URI;
+
+    /**
+     * Return Auth_OpenID_AX_Error if the mode in the attribute
+     * exchange arguments does not match what is expected for this
+     * class; true otherwise.
+     *
+     * @access private
+     */
+    function _checkMode($ax_args)
+    {
+        $mode = Auth_OpenID::arrayGet($ax_args, 'mode');
+        if ($mode != $this->mode) {
+            return new Auth_OpenID_AX_Error(
+                            sprintf(
+                                    "Expected mode '%s'; got '%s'",
+                                    $this->mode, $mode));
+        }
+
+        return true;
+    }
+
+    /**
+     * Return a set of attribute exchange arguments containing the
+     * basic information that must be in every attribute exchange
+     * message.
+     *
+     * @access private
+     */
+    function _newArgs()
+    {
+        return array('mode' => $this->mode);
+    }
+}
+
+/**
+ * Represents a single attribute in an attribute exchange
+ * request. This should be added to an AXRequest object in order to
+ * request the attribute.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_AttrInfo {
+    /**
+     * Construct an attribute information object.  Do not call this
+     * directly; call make(...) instead.
+     *
+     * @param string $type_uri The type URI for this attribute.
+     *
+     * @param int $count The number of values of this type to request.
+     *
+     * @param bool $required Whether the attribute will be marked as
+     * required in the request.
+     *
+     * @param string $alias The name that should be given to this
+     * attribute in the request.
+     */
+    function Auth_OpenID_AX_AttrInfo($type_uri, $count, $required,
+                                     $alias)
+    {
+        /**
+         * required: Whether the attribute will be marked as required
+         * when presented to the subject of the attribute exchange
+         * request.
+         */
+        $this->required = $required;
+
+        /**
+         * count: How many values of this type to request from the
+         * subject. Defaults to one.
+         */
+        $this->count = $count;
+
+        /**
+         * type_uri: The identifier that determines what the attribute
+         * represents and how it is serialized. For example, one type
+         * URI representing dates could represent a Unix timestamp in
+         * base 10 and another could represent a human-readable
+         * string.
+         */
+        $this->type_uri = $type_uri;
+
+        /**
+         * alias: The name that should be given to this attribute in
+         * the request. If it is not supplied, a generic name will be
+         * assigned. For example, if you want to call a Unix timestamp
+         * value 'tstamp', set its alias to that value. If two
+         * attributes in the same message request to use the same
+         * alias, the request will fail to be generated.
+         */
+        $this->alias = $alias;
+    }
+
+    /**
+     * Construct an attribute information object.  For parameter
+     * details, see the constructor.
+     */
+    function make($type_uri, $count=1, $required=false,
+                  $alias=null)
+    {
+        if ($alias !== null) {
+            $result = Auth_OpenID_AX_checkAlias($alias);
+
+            if (Auth_OpenID_AX::isError($result)) {
+                return $result;
+            }
+        }
+
+        return new Auth_OpenID_AX_AttrInfo($type_uri, $count, $required,
+                                           $alias);
+    }
+
+    /**
+     * When processing a request for this attribute, the OP should
+     * call this method to determine whether all available attribute
+     * values were requested.  If self.count == UNLIMITED_VALUES, this
+     * returns True.  Otherwise this returns False, in which case
+     * self.count is an integer.
+    */
+    function wantsUnlimitedValues()
+    {
+        return $this->count === Auth_OpenID_AX_UNLIMITED_VALUES;
+    }
+}
+
+/**
+ * Given a namespace mapping and a string containing a comma-separated
+ * list of namespace aliases, return a list of type URIs that
+ * correspond to those aliases.
+ *
+ * @param $namespace_map The mapping from namespace URI to alias
+ * @param $alias_list_s The string containing the comma-separated
+ * list of aliases. May also be None for convenience.
+ *
+ * @return $seq The list of namespace URIs that corresponds to the
+ * supplied list of aliases. If the string was zero-length or None, an
+ * empty list will be returned.
+ *
+ * return null If an alias is present in the list of aliases but
+ * is not present in the namespace map.
+ */
+function Auth_OpenID_AX_toTypeURIs(&$namespace_map, $alias_list_s)
+{
+    $uris = array();
+
+    if ($alias_list_s) {
+        foreach (explode(',', $alias_list_s) as $alias) {
+            $type_uri = $namespace_map->getNamespaceURI($alias);
+            if ($type_uri === null) {
+                // raise KeyError(
+                // 'No type is defined for attribute name %r' % (alias,))
+                return new Auth_OpenID_AX_Error(
+                  sprintf('No type is defined for attribute name %s',
+                          $alias)
+                  );
+            } else {
+                $uris[] = $type_uri;
+            }
+        }
+    }
+
+    return $uris;
+}
+
+/**
+ * An attribute exchange 'fetch_request' message. This message is sent
+ * by a relying party when it wishes to obtain attributes about the
+ * subject of an OpenID authentication request.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_FetchRequest extends Auth_OpenID_AX_Message {
+
+    var $mode = 'fetch_request';
+
+    function Auth_OpenID_AX_FetchRequest($update_url=null)
+    {
+        /**
+         * requested_attributes: The attributes that have been
+         * requested thus far, indexed by the type URI.
+         */
+        $this->requested_attributes = array();
+
+        /**
+         * update_url: A URL that will accept responses for this
+         * attribute exchange request, even in the absence of the user
+         * who made this request.
+        */
+        $this->update_url = $update_url;
+    }
+
+    /**
+     * Add an attribute to this attribute exchange request.
+     *
+     * @param attribute: The attribute that is being requested
+     * @return true on success, false when the requested attribute is
+     * already present in this fetch request.
+     */
+    function add($attribute)
+    {
+        if ($this->contains($attribute->type_uri)) {
+            return new Auth_OpenID_AX_Error(
+              sprintf("The attribute %s has already been requested",
+                      $attribute->type_uri));
+        }
+
+        $this->requested_attributes[$attribute->type_uri] = $attribute;
+
+        return true;
+    }
+
+    /**
+     * Get the serialized form of this attribute fetch request.
+     *
+     * @returns Auth_OpenID_AX_FetchRequest The fetch request message parameters
+     */
+    function getExtensionArgs()
+    {
+        $aliases = new Auth_OpenID_NamespaceMap();
+
+        $required = array();
+        $if_available = array();
+
+        $ax_args = $this->_newArgs();
+
+        foreach ($this->requested_attributes as $type_uri => $attribute) {
+            if ($attribute->alias === null) {
+                $alias = $aliases->add($type_uri);
+            } else {
+                $alias = $aliases->addAlias($type_uri, $attribute->alias);
+
+                if ($alias === null) {
+                    return new Auth_OpenID_AX_Error(
+                      sprintf("Could not add alias %s for URI %s",
+                              $attribute->alias, $type_uri
+                      ));
+                }
+            }
+
+            if ($attribute->required) {
+                $required[] = $alias;
+            } else {
+                $if_available[] = $alias;
+            }
+
+            if ($attribute->count != 1) {
+                $ax_args['count.' . $alias] = strval($attribute->count);
+            }
+
+            $ax_args['type.' . $alias] = $type_uri;
+        }
+
+        if ($required) {
+            $ax_args['required'] = implode(',', $required);
+        }
+
+        if ($if_available) {
+            $ax_args['if_available'] = implode(',', $if_available);
+        }
+
+        return $ax_args;
+    }
+
+    /**
+     * Get the type URIs for all attributes that have been marked as
+     * required.
+     *
+     * @return A list of the type URIs for attributes that have been
+     * marked as required.
+     */
+    function getRequiredAttrs()
+    {
+        $required = array();
+        foreach ($this->requested_attributes as $type_uri => $attribute) {
+            if ($attribute->required) {
+                $required[] = $type_uri;
+            }
+        }
+
+        return $required;
+    }
+
+    /**
+     * Extract a FetchRequest from an OpenID message
+     *
+     * @param request: The OpenID request containing the attribute
+     * fetch request
+     *
+     * @returns mixed An Auth_OpenID_AX_Error or the
+     * Auth_OpenID_AX_FetchRequest extracted from the request message if
+     * successful
+     */
+    function &fromOpenIDRequest($request)
+    {
+        $m = $request->message;
+        $obj = new Auth_OpenID_AX_FetchRequest();
+        $ax_args = $m->getArgs($obj->ns_uri);
+
+        $result = $obj->parseExtensionArgs($ax_args);
+
+        if (Auth_OpenID_AX::isError($result)) {
+            return $result;
+        }
+
+        if ($obj->update_url) {
+            // Update URL must match the openid.realm of the
+            // underlying OpenID 2 message.
+            $realm = $m->getArg(Auth_OpenID_OPENID_NS, 'realm',
+                        $m->getArg(
+                                  Auth_OpenID_OPENID_NS,
+                                  'return_to'));
+
+            if (!$realm) {
+                $obj = new Auth_OpenID_AX_Error(
+                  sprintf("Cannot validate update_url %s " .
+                          "against absent realm", $obj->update_url));
+            } else if (!Auth_OpenID_TrustRoot::match($realm,
+                                                     $obj->update_url)) {
+                $obj = new Auth_OpenID_AX_Error(
+                  sprintf("Update URL %s failed validation against realm %s",
+                          $obj->update_url, $realm));
+            }
+        }
+
+        return $obj;
+    }
+
+    /**
+     * Given attribute exchange arguments, populate this FetchRequest.
+     *
+     * @return $result Auth_OpenID_AX_Error if the data to be parsed
+     * does not follow the attribute exchange specification. At least
+     * when 'if_available' or 'required' is not specified for a
+     * particular attribute type.  Returns true otherwise.
+    */
+    function parseExtensionArgs($ax_args)
+    {
+        $result = $this->_checkMode($ax_args);
+        if (Auth_OpenID_AX::isError($result)) {
+            return $result;
+        }
+
+        $aliases = new Auth_OpenID_NamespaceMap();
+
+        foreach ($ax_args as $key => $value) {
+            if (strpos($key, 'type.') === 0) {
+                $alias = substr($key, 5);
+                $type_uri = $value;
+
+                $alias = $aliases->addAlias($type_uri, $alias);
+
+                if ($alias === null) {
+                    return new Auth_OpenID_AX_Error(
+                      sprintf("Could not add alias %s for URI %s",
+                              $alias, $type_uri)
+                      );
+                }
+
+                $count_s = Auth_OpenID::arrayGet($ax_args, 'count.' . $alias);
+                if ($count_s) {
+                    $count = Auth_OpenID::intval($count_s);
+                    if (($count === false) &&
+                        ($count_s === Auth_OpenID_AX_UNLIMITED_VALUES)) {
+                        $count = $count_s;
+                    }
+                } else {
+                    $count = 1;
+                }
+
+                if ($count === false) {
+                    return new Auth_OpenID_AX_Error(
+                      sprintf("Integer value expected for %s, got %s",
+                              'count.' . $alias, $count_s));
+                }
+
+                $attrinfo = Auth_OpenID_AX_AttrInfo::make($type_uri, $count,
+                                                          false, $alias);
+
+                if (Auth_OpenID_AX::isError($attrinfo)) {
+                    return $attrinfo;
+                }
+
+                $this->add($attrinfo);
+            }
+        }
+
+        $required = Auth_OpenID_AX_toTypeURIs($aliases,
+                         Auth_OpenID::arrayGet($ax_args, 'required'));
+
+        foreach ($required as $type_uri) {
+            $attrib =& $this->requested_attributes[$type_uri];
+            $attrib->required = true;
+        }
+
+        $if_available = Auth_OpenID_AX_toTypeURIs($aliases,
+                             Auth_OpenID::arrayGet($ax_args, 'if_available'));
+
+        $all_type_uris = array_merge($required, $if_available);
+
+        foreach ($aliases->iterNamespaceURIs() as $type_uri) {
+            if (!in_array($type_uri, $all_type_uris)) {
+                return new Auth_OpenID_AX_Error(
+                  sprintf('Type URI %s was in the request but not ' .
+                          'present in "required" or "if_available"',
+                          $type_uri));
+
+            }
+        }
+
+        $this->update_url = Auth_OpenID::arrayGet($ax_args, 'update_url');
+
+        return true;
+    }
+
+    /**
+     * Iterate over the AttrInfo objects that are contained in this
+     * fetch_request.
+     */
+    function iterAttrs()
+    {
+        return array_values($this->requested_attributes);
+    }
+
+    function iterTypes()
+    {
+        return array_keys($this->requested_attributes);
+    }
+
+    /**
+     * Is the given type URI present in this fetch_request?
+     */
+    function contains($type_uri)
+    {
+        return in_array($type_uri, $this->iterTypes());
+    }
+}
+
+/**
+ * An abstract class that implements a message that has attribute keys
+ * and values. It contains the common code between fetch_response and
+ * store_request.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_KeyValueMessage extends Auth_OpenID_AX_Message {
+
+    function Auth_OpenID_AX_KeyValueMessage()
+    {
+        $this->data = array();
+    }
+
+    /**
+     * Add a single value for the given attribute type to the
+     * message. If there are already values specified for this type,
+     * this value will be sent in addition to the values already
+     * specified.
+     *
+     * @param type_uri: The URI for the attribute
+     * @param value: The value to add to the response to the relying
+     * party for this attribute
+     * @return null
+     */
+    function addValue($type_uri, $value)
+    {
+        if (!array_key_exists($type_uri, $this->data)) {
+            $this->data[$type_uri] = array();
+        }
+
+        $values =& $this->data[$type_uri];
+        $values[] = $value;
+    }
+
+    /**
+     * Set the values for the given attribute type. This replaces any
+     * values that have already been set for this attribute.
+     *
+     * @param type_uri: The URI for the attribute
+     * @param values: A list of values to send for this attribute.
+     */
+    function setValues($type_uri, &$values)
+    {
+        $this->data[$type_uri] =& $values;
+    }
+
+    /**
+     * Get the extension arguments for the key/value pairs contained
+     * in this message.
+     *
+     * @param aliases: An alias mapping. Set to None if you don't care
+     * about the aliases for this request.
+     *
+     * @access private
+     */
+    function _getExtensionKVArgs(&$aliases)
+    {
+        if ($aliases === null) {
+            $aliases = new Auth_OpenID_NamespaceMap();
+        }
+
+        $ax_args = array();
+
+        foreach ($this->data as $type_uri => $values) {
+            $alias = $aliases->add($type_uri);
+
+            $ax_args['type.' . $alias] = $type_uri;
+            $ax_args['count.' . $alias] = strval(count($values));
+
+            foreach ($values as $i => $value) {
+              $key = sprintf('value.%s.%d', $alias, $i + 1);
+              $ax_args[$key] = $value;
+            }
+        }
+
+        return $ax_args;
+    }
+
+    /**
+     * Parse attribute exchange key/value arguments into this object.
+     *
+     * @param ax_args: The attribute exchange fetch_response
+     * arguments, with namespacing removed.
+     *
+     * @return Auth_OpenID_AX_Error or true
+     */
+    function parseExtensionArgs($ax_args)
+    {
+        $result = $this->_checkMode($ax_args);
+        if (Auth_OpenID_AX::isError($result)) {
+            return $result;
+        }
+
+        $aliases = new Auth_OpenID_NamespaceMap();
+
+        foreach ($ax_args as $key => $value) {
+            if (strpos($key, 'type.') === 0) {
+                $type_uri = $value;
+                $alias = substr($key, 5);
+
+                $result = Auth_OpenID_AX_checkAlias($alias);
+
+                if (Auth_OpenID_AX::isError($result)) {
+                    return $result;
+                }
+
+                $alias = $aliases->addAlias($type_uri, $alias);
+
+                if ($alias === null) {
+                    return new Auth_OpenID_AX_Error(
+                      sprintf("Could not add alias %s for URI %s",
+                              $alias, $type_uri)
+                      );
+                }
+            }
+        }
+
+        foreach ($aliases->iteritems() as $pair) {
+            list($type_uri, $alias) = $pair;
+
+            if (array_key_exists('count.' . $alias, $ax_args)) {
+
+                $count_key = 'count.' . $alias;
+                $count_s = $ax_args[$count_key];
+
+                $count = Auth_OpenID::intval($count_s);
+
+                if ($count === false) {
+                    return new Auth_OpenID_AX_Error(
+                      sprintf("Integer value expected for %s, got %s",
+                              'count. %s' . $alias, $count_s,
+                              Auth_OpenID_AX_UNLIMITED_VALUES)
+                                                    );
+                }
+
+                $values = array();
+                for ($i = 1; $i < $count + 1; $i++) {
+                    $value_key = sprintf('value.%s.%d', $alias, $i);
+
+                    if (!array_key_exists($value_key, $ax_args)) {
+                      return new Auth_OpenID_AX_Error(
+                        sprintf(
+                                "No value found for key %s",
+                                $value_key));
+                    }
+
+                    $value = $ax_args[$value_key];
+                    $values[] = $value;
+                }
+            } else {
+                $key = 'value.' . $alias;
+
+                if (!array_key_exists($key, $ax_args)) {
+                  return new Auth_OpenID_AX_Error(
+                    sprintf(
+                            "No value found for key %s",
+                            $key));
+                }
+
+                $value = $ax_args['value.' . $alias];
+
+                if ($value == '') {
+                    $values = array();
+                } else {
+                    $values = array($value);
+                }
+            }
+
+            $this->data[$type_uri] = $values;
+        }
+
+        return true;
+    }
+
+    /**
+     * Get a single value for an attribute. If no value was sent for
+     * this attribute, use the supplied default. If there is more than
+     * one value for this attribute, this method will fail.
+     *
+     * @param type_uri: The URI for the attribute
+     * @param default: The value to return if the attribute was not
+     * sent in the fetch_response.
+     *
+     * @return $value Auth_OpenID_AX_Error on failure or the value of
+     * the attribute in the fetch_response message, or the default
+     * supplied
+     */
+    function getSingle($type_uri, $default=null)
+    {
+        $values = Auth_OpenID::arrayGet($this->data, $type_uri);
+        if (!$values) {
+            return $default;
+        } else if (count($values) == 1) {
+            return $values[0];
+        } else {
+            return new Auth_OpenID_AX_Error(
+              sprintf('More than one value present for %s',
+                      $type_uri)
+              );
+        }
+    }
+
+    /**
+     * Get the list of values for this attribute in the
+     * fetch_response.
+     *
+     * XXX: what to do if the values are not present? default
+     * parameter? this is funny because it's always supposed to return
+     * a list, so the default may break that, though it's provided by
+     * the user's code, so it might be okay. If no default is
+     * supplied, should the return be None or []?
+     *
+     * @param type_uri: The URI of the attribute
+     *
+     * @return $values The list of values for this attribute in the
+     * response. May be an empty list.  If the attribute was not sent
+     * in the response, returns Auth_OpenID_AX_Error.
+     */
+    function get($type_uri)
+    {
+        if (array_key_exists($type_uri, $this->data)) {
+            return $this->data[$type_uri];
+        } else {
+            return new Auth_OpenID_AX_Error(
+              sprintf("Type URI %s not found in response",
+                      $type_uri)
+              );
+        }
+    }
+
+    /**
+     * Get the number of responses for a particular attribute in this
+     * fetch_response message.
+     *
+     * @param type_uri: The URI of the attribute
+     *
+     * @returns int The number of values sent for this attribute.  If
+     * the attribute was not sent in the response, returns
+     * Auth_OpenID_AX_Error.
+     */
+    function count($type_uri)
+    {
+        if (array_key_exists($type_uri, $this->data)) {
+            return count($this->get($type_uri));
+        } else {
+            return new Auth_OpenID_AX_Error(
+              sprintf("Type URI %s not found in response",
+                      $type_uri)
+              );
+        }
+    }
+}
+
+/**
+ * A fetch_response attribute exchange message.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_FetchResponse extends Auth_OpenID_AX_KeyValueMessage {
+    var $mode = 'fetch_response';
+
+    function Auth_OpenID_AX_FetchResponse($update_url=null)
+    {
+        $this->Auth_OpenID_AX_KeyValueMessage();
+        $this->update_url = $update_url;
+    }
+
+    /**
+     * Serialize this object into arguments in the attribute exchange
+     * namespace
+     *
+     * @return $args The dictionary of unqualified attribute exchange
+     * arguments that represent this fetch_response, or
+     * Auth_OpenID_AX_Error on error.
+     */
+    function getExtensionArgs($request=null)
+    {
+        $aliases = new Auth_OpenID_NamespaceMap();
+
+        $zero_value_types = array();
+
+        if ($request !== null) {
+            // Validate the data in the context of the request (the
+            // same attributes should be present in each, and the
+            // counts in the response must be no more than the counts
+            // in the request)
+
+            foreach ($this->data as $type_uri => $unused) {
+                if (!$request->contains($type_uri)) {
+                    return new Auth_OpenID_AX_Error(
+                      sprintf("Response attribute not present in request: %s",
+                              $type_uri)
+                      );
+                }
+            }
+
+            foreach ($request->iterAttrs() as $attr_info) {
+                // Copy the aliases from the request so that reading
+                // the response in light of the request is easier
+                if ($attr_info->alias === null) {
+                    $aliases->add($attr_info->type_uri);
+                } else {
+                    $alias = $aliases->addAlias($attr_info->type_uri,
+                                                $attr_info->alias);
+
+                    if ($alias === null) {
+                        return new Auth_OpenID_AX_Error(
+                          sprintf("Could not add alias %s for URI %s",
+                                  $attr_info->alias, $attr_info->type_uri)
+                          );
+                    }
+                }
+
+                if (array_key_exists($attr_info->type_uri, $this->data)) {
+                    $values = $this->data[$attr_info->type_uri];
+                } else {
+                    $values = array();
+                    $zero_value_types[] = $attr_info;
+                }
+
+                if (($attr_info->count != Auth_OpenID_AX_UNLIMITED_VALUES) &&
+                    ($attr_info->count < count($values))) {
+                    return new Auth_OpenID_AX_Error(
+                      sprintf("More than the number of requested values " .
+                              "were specified for %s",
+                              $attr_info->type_uri)
+                      );
+                }
+            }
+        }
+
+        $kv_args = $this->_getExtensionKVArgs($aliases);
+
+        // Add the KV args into the response with the args that are
+        // unique to the fetch_response
+        $ax_args = $this->_newArgs();
+
+        // For each requested attribute, put its type/alias and count
+        // into the response even if no data were returned.
+        foreach ($zero_value_types as $attr_info) {
+            $alias = $aliases->getAlias($attr_info->type_uri);
+            $kv_args['type.' . $alias] = $attr_info->type_uri;
+            $kv_args['count.' . $alias] = '0';
+        }
+
+        $update_url = null;
+        if ($request) {
+            $update_url = $request->update_url;
+        } else {
+            $update_url = $this->update_url;
+        }
+
+        if ($update_url) {
+            $ax_args['update_url'] = $update_url;
+        }
+
+        Auth_OpenID::update(&$ax_args, $kv_args);
+
+        return $ax_args;
+    }
+
+    /**
+     * @return $result Auth_OpenID_AX_Error on failure or true on
+     * success.
+     */
+    function parseExtensionArgs($ax_args)
+    {
+        $result = parent::parseExtensionArgs($ax_args);
+
+        if (Auth_OpenID_AX::isError($result)) {
+            return $result;
+        }
+
+        $this->update_url = Auth_OpenID::arrayGet($ax_args, 'update_url');
+
+        return true;
+    }
+
+    /**
+     * Construct a FetchResponse object from an OpenID library
+     * SuccessResponse object.
+     *
+     * @param success_response: A successful id_res response object
+     *
+     * @param signed: Whether non-signed args should be processsed. If
+     * True (the default), only signed arguments will be processsed.
+     *
+     * @return $response A FetchResponse containing the data from the
+     * OpenID message
+     */
+    function fromSuccessResponse($success_response, $signed=true)
+    {
+        $obj = new Auth_OpenID_AX_FetchResponse();
+        if ($signed) {
+            $ax_args = $success_response->getSignedNS($obj->ns_uri);
+        } else {
+            $ax_args = $success_response->message->getArgs($obj->ns_uri);
+        }
+        if ($ax_args === null || Auth_OpenID::isFailure($ax_args) ||
+              sizeof($ax_args) == 0) {
+            return null;
+        }
+
+        $result = $obj->parseExtensionArgs($ax_args);
+        if (Auth_OpenID_AX::isError($result)) {
+            #XXX log me
+            return null;
+        }
+        return $obj;
+    }
+}
+
+/**
+ * A store request attribute exchange message representation.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_StoreRequest extends Auth_OpenID_AX_KeyValueMessage {
+    var $mode = 'store_request';
+
+    /**
+     * @param array $aliases The namespace aliases to use when making
+     * this store response. Leave as None to use defaults.
+     */
+    function getExtensionArgs($aliases=null)
+    {
+        $ax_args = $this->_newArgs();
+        $kv_args = $this->_getExtensionKVArgs($aliases);
+        Auth_OpenID::update(&$ax_args, $kv_args);
+        return $ax_args;
+    }
+}
+
+/**
+ * An indication that the store request was processed along with this
+ * OpenID transaction.  Use make(), NOT the constructor, to create
+ * response objects.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AX_StoreResponse extends Auth_OpenID_AX_Message {
+    var $SUCCESS_MODE = 'store_response_success';
+    var $FAILURE_MODE = 'store_response_failure';
+
+    /**
+     * Returns Auth_OpenID_AX_Error on error or an
+     * Auth_OpenID_AX_StoreResponse object on success.
+     */
+    function &make($succeeded=true, $error_message=null)
+    {
+        if (($succeeded) && ($error_message !== null)) {
+            return new Auth_OpenID_AX_Error('An error message may only be '.
+                                    'included in a failing fetch response');
+        }
+
+        return new Auth_OpenID_AX_StoreResponse($succeeded, $error_message);
+    }
+
+    function Auth_OpenID_AX_StoreResponse($succeeded=true, $error_message=null)
+    {
+        if ($succeeded) {
+            $this->mode = $this->SUCCESS_MODE;
+        } else {
+            $this->mode = $this->FAILURE_MODE;
+        }
+
+        $this->error_message = $error_message;
+    }
+
+    /**
+     * Was this response a success response?
+     */
+    function succeeded()
+    {
+        return $this->mode == $this->SUCCESS_MODE;
+    }
+
+    function getExtensionArgs()
+    {
+        $ax_args = $this->_newArgs();
+        if ((!$this->succeeded()) && $this->error_message) {
+            $ax_args['error'] = $this->error_message;
+        }
+
+        return $ax_args;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/Association.php b/inc/lib/Auth/OpenID/Association.php
new file mode 100644 (file)
index 0000000..37ce0cb
--- /dev/null
@@ -0,0 +1,613 @@
+<?php
+
+/**
+ * This module contains code for dealing with associations between
+ * consumers and servers.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * @access private
+ */
+require_once 'Auth/OpenID/CryptUtil.php';
+
+/**
+ * @access private
+ */
+require_once 'Auth/OpenID/KVForm.php';
+
+/**
+ * @access private
+ */
+require_once 'Auth/OpenID/HMAC.php';
+
+/**
+ * This class represents an association between a server and a
+ * consumer.  In general, users of this library will never see
+ * instances of this object.  The only exception is if you implement a
+ * custom {@link Auth_OpenID_OpenIDStore}.
+ *
+ * If you do implement such a store, it will need to store the values
+ * of the handle, secret, issued, lifetime, and assoc_type instance
+ * variables.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Association {
+
+    /**
+     * This is a HMAC-SHA1 specific value.
+     *
+     * @access private
+     */
+    var $SIG_LENGTH = 20;
+
+    /**
+     * The ordering and name of keys as stored by serialize.
+     *
+     * @access private
+     */
+    var $assoc_keys = array(
+                            'version',
+                            'handle',
+                            'secret',
+                            'issued',
+                            'lifetime',
+                            'assoc_type'
+                            );
+
+    var $_macs = array(
+                       'HMAC-SHA1' => 'Auth_OpenID_HMACSHA1',
+                       'HMAC-SHA256' => 'Auth_OpenID_HMACSHA256'
+                       );
+
+    /**
+     * This is an alternate constructor (factory method) used by the
+     * OpenID consumer library to create associations.  OpenID store
+     * implementations shouldn't use this constructor.
+     *
+     * @access private
+     *
+     * @param integer $expires_in This is the amount of time this
+     * association is good for, measured in seconds since the
+     * association was issued.
+     *
+     * @param string $handle This is the handle the server gave this
+     * association.
+     *
+     * @param string secret This is the shared secret the server
+     * generated for this association.
+     *
+     * @param assoc_type This is the type of association this
+     * instance represents.  The only valid values of this field at
+     * this time is 'HMAC-SHA1' and 'HMAC-SHA256', but new types may
+     * be defined in the future.
+     *
+     * @return association An {@link Auth_OpenID_Association}
+     * instance.
+     */
+    function fromExpiresIn($expires_in, $handle, $secret, $assoc_type)
+    {
+        $issued = time();
+        $lifetime = $expires_in;
+        return new Auth_OpenID_Association($handle, $secret,
+                                           $issued, $lifetime, $assoc_type);
+    }
+
+    /**
+     * This is the standard constructor for creating an association.
+     * The library should create all of the necessary associations, so
+     * this constructor is not part of the external API.
+     *
+     * @access private
+     *
+     * @param string $handle This is the handle the server gave this
+     * association.
+     *
+     * @param string $secret This is the shared secret the server
+     * generated for this association.
+     *
+     * @param integer $issued This is the time this association was
+     * issued, in seconds since 00:00 GMT, January 1, 1970.  (ie, a
+     * unix timestamp)
+     *
+     * @param integer $lifetime This is the amount of time this
+     * association is good for, measured in seconds since the
+     * association was issued.
+     *
+     * @param string $assoc_type This is the type of association this
+     * instance represents.  The only valid values of this field at
+     * this time is 'HMAC-SHA1' and 'HMAC-SHA256', but new types may
+     * be defined in the future.
+     */
+    function Auth_OpenID_Association(
+        $handle, $secret, $issued, $lifetime, $assoc_type)
+    {
+        if (!in_array($assoc_type,
+                      Auth_OpenID_getSupportedAssociationTypes())) {
+            $fmt = 'Unsupported association type (%s)';
+            trigger_error(sprintf($fmt, $assoc_type), E_USER_ERROR);
+        }
+
+        $this->handle = $handle;
+        $this->secret = $secret;
+        $this->issued = $issued;
+        $this->lifetime = $lifetime;
+        $this->assoc_type = $assoc_type;
+    }
+
+    /**
+     * This returns the number of seconds this association is still
+     * valid for, or 0 if the association is no longer valid.
+     *
+     * @return integer $seconds The number of seconds this association
+     * is still valid for, or 0 if the association is no longer valid.
+     */
+    function getExpiresIn($now = null)
+    {
+        if ($now == null) {
+            $now = time();
+        }
+
+        return max(0, $this->issued + $this->lifetime - $now);
+    }
+
+    /**
+     * This checks to see if two {@link Auth_OpenID_Association}
+     * instances represent the same association.
+     *
+     * @return bool $result true if the two instances represent the
+     * same association, false otherwise.
+     */
+    function equal($other)
+    {
+        return ((gettype($this) == gettype($other))
+                && ($this->handle == $other->handle)
+                && ($this->secret == $other->secret)
+                && ($this->issued == $other->issued)
+                && ($this->lifetime == $other->lifetime)
+                && ($this->assoc_type == $other->assoc_type));
+    }
+
+    /**
+     * Convert an association to KV form.
+     *
+     * @return string $result String in KV form suitable for
+     * deserialization by deserialize.
+     */
+    function serialize()
+    {
+        $data = array(
+                     'version' => '2',
+                     'handle' => $this->handle,
+                     'secret' => base64_encode($this->secret),
+                     'issued' => strval(intval($this->issued)),
+                     'lifetime' => strval(intval($this->lifetime)),
+                     'assoc_type' => $this->assoc_type
+                     );
+
+        assert(array_keys($data) == $this->assoc_keys);
+
+        return Auth_OpenID_KVForm::fromArray($data, $strict = true);
+    }
+
+    /**
+     * Parse an association as stored by serialize().  This is the
+     * inverse of serialize.
+     *
+     * @param string $assoc_s Association as serialized by serialize()
+     * @return Auth_OpenID_Association $result instance of this class
+     */
+    function deserialize($class_name, $assoc_s)
+    {
+        $pairs = Auth_OpenID_KVForm::toArray($assoc_s, $strict = true);
+        $keys = array();
+        $values = array();
+        foreach ($pairs as $key => $value) {
+            if (is_array($value)) {
+                list($key, $value) = $value;
+            }
+            $keys[] = $key;
+            $values[] = $value;
+        }
+
+        $class_vars = get_class_vars($class_name);
+        $class_assoc_keys = $class_vars['assoc_keys'];
+
+        sort($keys);
+        sort($class_assoc_keys);
+
+        if ($keys != $class_assoc_keys) {
+            trigger_error('Unexpected key values: ' . var_export($keys, true),
+                          E_USER_WARNING);
+            return null;
+        }
+
+        $version = $pairs['version'];
+        $handle = $pairs['handle'];
+        $secret = $pairs['secret'];
+        $issued = $pairs['issued'];
+        $lifetime = $pairs['lifetime'];
+        $assoc_type = $pairs['assoc_type'];
+
+        if ($version != '2') {
+            trigger_error('Unknown version: ' . $version, E_USER_WARNING);
+            return null;
+        }
+
+        $issued = intval($issued);
+        $lifetime = intval($lifetime);
+        $secret = base64_decode($secret);
+
+        return new $class_name(
+            $handle, $secret, $issued, $lifetime, $assoc_type);
+    }
+
+    /**
+     * Generate a signature for a sequence of (key, value) pairs
+     *
+     * @access private
+     * @param array $pairs The pairs to sign, in order.  This is an
+     * array of two-tuples.
+     * @return string $signature The binary signature of this sequence
+     * of pairs
+     */
+    function sign($pairs)
+    {
+        $kv = Auth_OpenID_KVForm::fromArray($pairs);
+
+        /* Invalid association types should be caught at constructor */
+        $callback = $this->_macs[$this->assoc_type];
+
+        return call_user_func_array($callback, array($this->secret, $kv));
+    }
+
+    /**
+     * Generate a signature for some fields in a dictionary
+     *
+     * @access private
+     * @param array $fields The fields to sign, in order; this is an
+     * array of strings.
+     * @param array $data Dictionary of values to sign (an array of
+     * string => string pairs).
+     * @return string $signature The signature, base64 encoded
+     */
+    function signMessage($message)
+    {
+        if ($message->hasKey(Auth_OpenID_OPENID_NS, 'sig') ||
+            $message->hasKey(Auth_OpenID_OPENID_NS, 'signed')) {
+            // Already has a sig
+            return null;
+        }
+
+        $extant_handle = $message->getArg(Auth_OpenID_OPENID_NS,
+                                          'assoc_handle');
+
+        if ($extant_handle && ($extant_handle != $this->handle)) {
+            // raise ValueError("Message has a different association handle")
+            return null;
+        }
+
+        $signed_message = $message;
+        $signed_message->setArg(Auth_OpenID_OPENID_NS, 'assoc_handle',
+                                $this->handle);
+
+        $message_keys = array_keys($signed_message->toPostArgs());
+        $signed_list = array();
+        $signed_prefix = 'openid.';
+
+        foreach ($message_keys as $k) {
+            if (strpos($k, $signed_prefix) === 0) {
+                $signed_list[] = substr($k, strlen($signed_prefix));
+            }
+        }
+
+        $signed_list[] = 'signed';
+        sort($signed_list);
+
+        $signed_message->setArg(Auth_OpenID_OPENID_NS, 'signed',
+                                implode(',', $signed_list));
+        $sig = $this->getMessageSignature($signed_message);
+        $signed_message->setArg(Auth_OpenID_OPENID_NS, 'sig', $sig);
+        return $signed_message;
+    }
+
+    /**
+     * Given a {@link Auth_OpenID_Message}, return the key/value pairs
+     * to be signed according to the signed list in the message.  If
+     * the message lacks a signed list, return null.
+     *
+     * @access private
+     */
+    function _makePairs(&$message)
+    {
+        $signed = $message->getArg(Auth_OpenID_OPENID_NS, 'signed');
+        if (!$signed || Auth_OpenID::isFailure($signed)) {
+            // raise ValueError('Message has no signed list: %s' % (message,))
+            return null;
+        }
+
+        $signed_list = explode(',', $signed);
+        $pairs = array();
+        $data = $message->toPostArgs();
+        foreach ($signed_list as $field) {
+            $pairs[] = array($field, Auth_OpenID::arrayGet($data,
+                                                           'openid.' .
+                                                           $field, ''));
+        }
+        return $pairs;
+    }
+
+    /**
+     * Given an {@link Auth_OpenID_Message}, return the signature for
+     * the signed list in the message.
+     *
+     * @access private
+     */
+    function getMessageSignature(&$message)
+    {
+        $pairs = $this->_makePairs($message);
+        return base64_encode($this->sign($pairs));
+    }
+
+    /**
+     * Confirm that the signature of these fields matches the
+     * signature contained in the data.
+     *
+     * @access private
+     */
+    function checkMessageSignature(&$message)
+    {
+        $sig = $message->getArg(Auth_OpenID_OPENID_NS,
+                                'sig');
+
+        if (!$sig || Auth_OpenID::isFailure($sig)) {
+            return false;
+        }
+
+        $calculated_sig = $this->getMessageSignature($message);
+        return $calculated_sig == $sig;
+    }
+}
+
+function Auth_OpenID_getSecretSize($assoc_type)
+{
+    if ($assoc_type == 'HMAC-SHA1') {
+        return 20;
+    } else if ($assoc_type == 'HMAC-SHA256') {
+        return 32;
+    } else {
+        return null;
+    }
+}
+
+function Auth_OpenID_getAllAssociationTypes()
+{
+    return array('HMAC-SHA1', 'HMAC-SHA256');
+}
+
+function Auth_OpenID_getSupportedAssociationTypes()
+{
+    $a = array('HMAC-SHA1');
+
+    if (Auth_OpenID_HMACSHA256_SUPPORTED) {
+        $a[] = 'HMAC-SHA256';
+    }
+
+    return $a;
+}
+
+function Auth_OpenID_getSessionTypes($assoc_type)
+{
+    $assoc_to_session = array(
+       'HMAC-SHA1' => array('DH-SHA1', 'no-encryption'));
+
+    if (Auth_OpenID_HMACSHA256_SUPPORTED) {
+        $assoc_to_session['HMAC-SHA256'] =
+            array('DH-SHA256', 'no-encryption');
+    }
+
+    return Auth_OpenID::arrayGet($assoc_to_session, $assoc_type, array());
+}
+
+function Auth_OpenID_checkSessionType($assoc_type, $session_type)
+{
+    if (!in_array($session_type,
+                  Auth_OpenID_getSessionTypes($assoc_type))) {
+        return false;
+    }
+
+    return true;
+}
+
+function Auth_OpenID_getDefaultAssociationOrder()
+{
+    $order = array();
+
+    if (!Auth_OpenID_noMathSupport()) {
+        $order[] = array('HMAC-SHA1', 'DH-SHA1');
+
+        if (Auth_OpenID_HMACSHA256_SUPPORTED) {
+            $order[] = array('HMAC-SHA256', 'DH-SHA256');
+        }
+    }
+
+    $order[] = array('HMAC-SHA1', 'no-encryption');
+
+    if (Auth_OpenID_HMACSHA256_SUPPORTED) {
+        $order[] = array('HMAC-SHA256', 'no-encryption');
+    }
+
+    return $order;
+}
+
+function Auth_OpenID_getOnlyEncryptedOrder()
+{
+    $result = array();
+
+    foreach (Auth_OpenID_getDefaultAssociationOrder() as $pair) {
+        list($assoc, $session) = $pair;
+
+        if ($session != 'no-encryption') {
+            if (Auth_OpenID_HMACSHA256_SUPPORTED &&
+                ($assoc == 'HMAC-SHA256')) {
+                $result[] = $pair;
+            } else if ($assoc != 'HMAC-SHA256') {
+                $result[] = $pair;
+            }
+        }
+    }
+
+    return $result;
+}
+
+function &Auth_OpenID_getDefaultNegotiator()
+{
+    $x = new Auth_OpenID_SessionNegotiator(
+                 Auth_OpenID_getDefaultAssociationOrder());
+    return $x;
+}
+
+function &Auth_OpenID_getEncryptedNegotiator()
+{
+    $x = new Auth_OpenID_SessionNegotiator(
+                 Auth_OpenID_getOnlyEncryptedOrder());
+    return $x;
+}
+
+/**
+ * A session negotiator controls the allowed and preferred association
+ * types and association session types. Both the {@link
+ * Auth_OpenID_Consumer} and {@link Auth_OpenID_Server} use
+ * negotiators when creating associations.
+ *
+ * You can create and use negotiators if you:
+
+ * - Do not want to do Diffie-Hellman key exchange because you use
+ * transport-layer encryption (e.g. SSL)
+ *
+ * - Want to use only SHA-256 associations
+ *
+ * - Do not want to support plain-text associations over a non-secure
+ * channel
+ *
+ * It is up to you to set a policy for what kinds of associations to
+ * accept. By default, the library will make any kind of association
+ * that is allowed in the OpenID 2.0 specification.
+ *
+ * Use of negotiators in the library
+ * =================================
+ *
+ * When a consumer makes an association request, it calls {@link
+ * getAllowedType} to get the preferred association type and
+ * association session type.
+ *
+ * The server gets a request for a particular association/session type
+ * and calls {@link isAllowed} to determine if it should create an
+ * association. If it is supported, negotiation is complete. If it is
+ * not, the server calls {@link getAllowedType} to get an allowed
+ * association type to return to the consumer.
+ *
+ * If the consumer gets an error response indicating that the
+ * requested association/session type is not supported by the server
+ * that contains an assocation/session type to try, it calls {@link
+ * isAllowed} to determine if it should try again with the given
+ * combination of association/session type.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SessionNegotiator {
+    function Auth_OpenID_SessionNegotiator($allowed_types)
+    {
+        $this->allowed_types = array();
+        $this->setAllowedTypes($allowed_types);
+    }
+
+    /**
+     * Set the allowed association types, checking to make sure each
+     * combination is valid.
+     *
+     * @access private
+     */
+    function setAllowedTypes($allowed_types)
+    {
+        foreach ($allowed_types as $pair) {
+            list($assoc_type, $session_type) = $pair;
+            if (!Auth_OpenID_checkSessionType($assoc_type, $session_type)) {
+                return false;
+            }
+        }
+
+        $this->allowed_types = $allowed_types;
+        return true;
+    }
+
+    /**
+     * Add an association type and session type to the allowed types
+     * list. The assocation/session pairs are tried in the order that
+     * they are added.
+     *
+     * @access private
+     */
+    function addAllowedType($assoc_type, $session_type = null)
+    {
+        if ($this->allowed_types === null) {
+            $this->allowed_types = array();
+        }
+
+        if ($session_type === null) {
+            $available = Auth_OpenID_getSessionTypes($assoc_type);
+
+            if (!$available) {
+                return false;
+            }
+
+            foreach ($available as $session_type) {
+                $this->addAllowedType($assoc_type, $session_type);
+            }
+        } else {
+            if (Auth_OpenID_checkSessionType($assoc_type, $session_type)) {
+                $this->allowed_types[] = array($assoc_type, $session_type);
+            } else {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    // Is this combination of association type and session type allowed?
+    function isAllowed($assoc_type, $session_type)
+    {
+        $assoc_good = in_array(array($assoc_type, $session_type),
+                               $this->allowed_types);
+
+        $matches = in_array($session_type,
+                            Auth_OpenID_getSessionTypes($assoc_type));
+
+        return ($assoc_good && $matches);
+    }
+
+    /**
+     * Get a pair of assocation type and session type that are
+     * supported.
+     */
+    function getAllowedType()
+    {
+        if (!$this->allowed_types) {
+            return array(null, null);
+        }
+
+        return $this->allowed_types[0];
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/BigMath.php b/inc/lib/Auth/OpenID/BigMath.php
new file mode 100644 (file)
index 0000000..6d99c4c
--- /dev/null
@@ -0,0 +1,471 @@
+<?php
+
+/**
+ * BigMath: A math library wrapper that abstracts out the underlying
+ * long integer library.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @access private
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Needed for random number generation
+ */
+require_once 'Auth/OpenID/CryptUtil.php';
+
+/**
+ * Need Auth_OpenID::bytes().
+ */
+require_once 'Auth/OpenID.php';
+
+/**
+ * The superclass of all big-integer math implementations
+ * @access private
+ * @package OpenID
+ */
+class Auth_OpenID_MathLibrary {
+    /**
+     * Given a long integer, returns the number converted to a binary
+     * string.  This function accepts long integer values of arbitrary
+     * magnitude and uses the local large-number math library when
+     * available.
+     *
+     * @param integer $long The long number (can be a normal PHP
+     * integer or a number created by one of the available long number
+     * libraries)
+     * @return string $binary The binary version of $long
+     */
+    function longToBinary($long)
+    {
+        $cmp = $this->cmp($long, 0);
+        if ($cmp < 0) {
+            $msg = __FUNCTION__ . " takes only positive integers.";
+            trigger_error($msg, E_USER_ERROR);
+            return null;
+        }
+
+        if ($cmp == 0) {
+            return "\x00";
+        }
+
+        $bytes = array();
+
+        while ($this->cmp($long, 0) > 0) {
+            array_unshift($bytes, $this->mod($long, 256));
+            $long = $this->div($long, pow(2, 8));
+        }
+
+        if ($bytes && ($bytes[0] > 127)) {
+            array_unshift($bytes, 0);
+        }
+
+        $string = '';
+        foreach ($bytes as $byte) {
+            $string .= pack('C', $byte);
+        }
+
+        return $string;
+    }
+
+    /**
+     * Given a binary string, returns the binary string converted to a
+     * long number.
+     *
+     * @param string $binary The binary version of a long number,
+     * probably as a result of calling longToBinary
+     * @return integer $long The long number equivalent of the binary
+     * string $str
+     */
+    function binaryToLong($str)
+    {
+        if ($str === null) {
+            return null;
+        }
+
+        // Use array_merge to return a zero-indexed array instead of a
+        // one-indexed array.
+        $bytes = array_merge(unpack('C*', $str));
+
+        $n = $this->init(0);
+
+        if ($bytes && ($bytes[0] > 127)) {
+            trigger_error("bytesToNum works only for positive integers.",
+                          E_USER_WARNING);
+            return null;
+        }
+
+        foreach ($bytes as $byte) {
+            $n = $this->mul($n, pow(2, 8));
+            $n = $this->add($n, $byte);
+        }
+
+        return $n;
+    }
+
+    function base64ToLong($str)
+    {
+        $b64 = base64_decode($str);
+
+        if ($b64 === false) {
+            return false;
+        }
+
+        return $this->binaryToLong($b64);
+    }
+
+    function longToBase64($str)
+    {
+        return base64_encode($this->longToBinary($str));
+    }
+
+    /**
+     * Returns a random number in the specified range.  This function
+     * accepts $start, $stop, and $step values of arbitrary magnitude
+     * and will utilize the local large-number math library when
+     * available.
+     *
+     * @param integer $start The start of the range, or the minimum
+     * random number to return
+     * @param integer $stop The end of the range, or the maximum
+     * random number to return
+     * @param integer $step The step size, such that $result - ($step
+     * * N) = $start for some N
+     * @return integer $result The resulting randomly-generated number
+     */
+    function rand($stop)
+    {
+        static $duplicate_cache = array();
+
+        // Used as the key for the duplicate cache
+        $rbytes = $this->longToBinary($stop);
+
+        if (array_key_exists($rbytes, $duplicate_cache)) {
+            list($duplicate, $nbytes) = $duplicate_cache[$rbytes];
+        } else {
+            if ($rbytes[0] == "\x00") {
+                $nbytes = Auth_OpenID::bytes($rbytes) - 1;
+            } else {
+                $nbytes = Auth_OpenID::bytes($rbytes);
+            }
+
+            $mxrand = $this->pow(256, $nbytes);
+
+            // If we get a number less than this, then it is in the
+            // duplicated range.
+            $duplicate = $this->mod($mxrand, $stop);
+
+            if (count($duplicate_cache) > 10) {
+                $duplicate_cache = array();
+            }
+
+            $duplicate_cache[$rbytes] = array($duplicate, $nbytes);
+        }
+
+        do {
+            $bytes = "\x00" . Auth_OpenID_CryptUtil::getBytes($nbytes);
+            $n = $this->binaryToLong($bytes);
+            // Keep looping if this value is in the low duplicated range
+        } while ($this->cmp($n, $duplicate) < 0);
+
+        return $this->mod($n, $stop);
+    }
+}
+
+/**
+ * Exposes BCmath math library functionality.
+ *
+ * {@link Auth_OpenID_BcMathWrapper} wraps the functionality provided
+ * by the BCMath extension.
+ *
+ * @access private
+ * @package OpenID
+ */
+class Auth_OpenID_BcMathWrapper extends Auth_OpenID_MathLibrary{
+    var $type = 'bcmath';
+
+    function add($x, $y)
+    {
+        return bcadd($x, $y);
+    }
+
+    function sub($x, $y)
+    {
+        return bcsub($x, $y);
+    }
+
+    function pow($base, $exponent)
+    {
+        return bcpow($base, $exponent);
+    }
+
+    function cmp($x, $y)
+    {
+        return bccomp($x, $y);
+    }
+
+    function init($number, $base = 10)
+    {
+        return $number;
+    }
+
+    function mod($base, $modulus)
+    {
+        return bcmod($base, $modulus);
+    }
+
+    function mul($x, $y)
+    {
+        return bcmul($x, $y);
+    }
+
+    function div($x, $y)
+    {
+        return bcdiv($x, $y);
+    }
+
+    /**
+     * Same as bcpowmod when bcpowmod is missing
+     *
+     * @access private
+     */
+    function _powmod($base, $exponent, $modulus)
+    {
+        $square = $this->mod($base, $modulus);
+        $result = 1;
+        while($this->cmp($exponent, 0) > 0) {
+            if ($this->mod($exponent, 2)) {
+                $result = $this->mod($this->mul($result, $square), $modulus);
+            }
+            $square = $this->mod($this->mul($square, $square), $modulus);
+            $exponent = $this->div($exponent, 2);
+        }
+        return $result;
+    }
+
+    function powmod($base, $exponent, $modulus)
+    {
+        if (function_exists('bcpowmod')) {
+            return bcpowmod($base, $exponent, $modulus);
+        } else {
+            return $this->_powmod($base, $exponent, $modulus);
+        }
+    }
+
+    function toString($num)
+    {
+        return $num;
+    }
+}
+
+/**
+ * Exposes GMP math library functionality.
+ *
+ * {@link Auth_OpenID_GmpMathWrapper} wraps the functionality provided
+ * by the GMP extension.
+ *
+ * @access private
+ * @package OpenID
+ */
+class Auth_OpenID_GmpMathWrapper extends Auth_OpenID_MathLibrary{
+    var $type = 'gmp';
+
+    function add($x, $y)
+    {
+        return gmp_add($x, $y);
+    }
+
+    function sub($x, $y)
+    {
+        return gmp_sub($x, $y);
+    }
+
+    function pow($base, $exponent)
+    {
+        return gmp_pow($base, $exponent);
+    }
+
+    function cmp($x, $y)
+    {
+        return gmp_cmp($x, $y);
+    }
+
+    function init($number, $base = 10)
+    {
+        return gmp_init($number, $base);
+    }
+
+    function mod($base, $modulus)
+    {
+        return gmp_mod($base, $modulus);
+    }
+
+    function mul($x, $y)
+    {
+        return gmp_mul($x, $y);
+    }
+
+    function div($x, $y)
+    {
+        return gmp_div_q($x, $y);
+    }
+
+    function powmod($base, $exponent, $modulus)
+    {
+        return gmp_powm($base, $exponent, $modulus);
+    }
+
+    function toString($num)
+    {
+        return gmp_strval($num);
+    }
+}
+
+/**
+ * Define the supported extensions.  An extension array has keys
+ * 'modules', 'extension', and 'class'.  'modules' is an array of PHP
+ * module names which the loading code will attempt to load.  These
+ * values will be suffixed with a library file extension (e.g. ".so").
+ * 'extension' is the name of a PHP extension which will be tested
+ * before 'modules' are loaded.  'class' is the string name of a
+ * {@link Auth_OpenID_MathWrapper} subclass which should be
+ * instantiated if a given extension is present.
+ *
+ * You can define new math library implementations and add them to
+ * this array.
+ */
+function Auth_OpenID_math_extensions()
+{
+    $result = array();
+
+    if (!defined('Auth_OpenID_BUGGY_GMP')) {
+        $result[] =
+            array('modules' => array('gmp', 'php_gmp'),
+                  'extension' => 'gmp',
+                  'class' => 'Auth_OpenID_GmpMathWrapper');
+    }
+
+    $result[] = array(
+                      'modules' => array('bcmath', 'php_bcmath'),
+                      'extension' => 'bcmath',
+                      'class' => 'Auth_OpenID_BcMathWrapper');
+
+    return $result;
+}
+
+/**
+ * Detect which (if any) math library is available
+ */
+function Auth_OpenID_detectMathLibrary($exts)
+{
+    $loaded = false;
+
+    foreach ($exts as $extension) {
+        // See if the extension specified is already loaded.
+        if ($extension['extension'] &&
+            extension_loaded($extension['extension'])) {
+            $loaded = true;
+        }
+
+        // Try to load dynamic modules.
+        if (!$loaded && function_exists('dl')) {
+            foreach ($extension['modules'] as $module) {
+                if (@dl($module . "." . PHP_SHLIB_SUFFIX)) {
+                    $loaded = true;
+                    break;
+                }
+            }
+        }
+
+        // If the load succeeded, supply an instance of
+        // Auth_OpenID_MathWrapper which wraps the specified
+        // module's functionality.
+        if ($loaded) {
+            return $extension;
+        }
+    }
+
+    return false;
+}
+
+/**
+ * {@link Auth_OpenID_getMathLib} checks for the presence of long
+ * number extension modules and returns an instance of
+ * {@link Auth_OpenID_MathWrapper} which exposes the module's
+ * functionality.
+ *
+ * Checks for the existence of an extension module described by the
+ * result of {@link Auth_OpenID_math_extensions()} and returns an
+ * instance of a wrapper for that extension module.  If no extension
+ * module is found, an instance of {@link Auth_OpenID_MathWrapper} is
+ * returned, which wraps the native PHP integer implementation.  The
+ * proper calling convention for this method is $lib =&
+ * Auth_OpenID_getMathLib().
+ *
+ * This function checks for the existence of specific long number
+ * implementations in the following order: GMP followed by BCmath.
+ *
+ * @return Auth_OpenID_MathWrapper $instance An instance of
+ * {@link Auth_OpenID_MathWrapper} or one of its subclasses
+ *
+ * @package OpenID
+ */
+function &Auth_OpenID_getMathLib()
+{
+    // The instance of Auth_OpenID_MathWrapper that we choose to
+    // supply will be stored here, so that subseqent calls to this
+    // method will return a reference to the same object.
+    static $lib = null;
+
+    if (isset($lib)) {
+        return $lib;
+    }
+
+    if (Auth_OpenID_noMathSupport()) {
+        $null = null;
+        return $null;
+    }
+
+    // If this method has not been called before, look at
+    // Auth_OpenID_math_extensions and try to find an extension that
+    // works.
+    $ext = Auth_OpenID_detectMathLibrary(Auth_OpenID_math_extensions());
+    if ($ext === false) {
+        $tried = array();
+        foreach (Auth_OpenID_math_extensions() as $extinfo) {
+            $tried[] = $extinfo['extension'];
+        }
+        $triedstr = implode(", ", $tried);
+
+        Auth_OpenID_setNoMathSupport();
+
+        $result = null;
+        return $result;
+    }
+
+    // Instantiate a new wrapper
+    $class = $ext['class'];
+    $lib = new $class();
+
+    return $lib;
+}
+
+function Auth_OpenID_setNoMathSupport()
+{
+    if (!defined('Auth_OpenID_NO_MATH_SUPPORT')) {
+        define('Auth_OpenID_NO_MATH_SUPPORT', true);
+    }
+}
+
+function Auth_OpenID_noMathSupport()
+{
+    return defined('Auth_OpenID_NO_MATH_SUPPORT');
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/Consumer.php b/inc/lib/Auth/OpenID/Consumer.php
new file mode 100644 (file)
index 0000000..cc0ab24
--- /dev/null
@@ -0,0 +1,2230 @@
+<?php
+
+/**
+ * This module documents the main interface with the OpenID consumer
+ * library.  The only part of the library which has to be used and
+ * isn't documented in full here is the store required to create an
+ * Auth_OpenID_Consumer instance.  More on the abstract store type and
+ * concrete implementations of it that are provided in the
+ * documentation for the Auth_OpenID_Consumer constructor.
+ *
+ * OVERVIEW
+ *
+ * The OpenID identity verification process most commonly uses the
+ * following steps, as visible to the user of this library:
+ *
+ *   1. The user enters their OpenID into a field on the consumer's
+ *      site, and hits a login button.
+ *   2. The consumer site discovers the user's OpenID server using the
+ *      YADIS protocol.
+ *   3. The consumer site sends the browser a redirect to the identity
+ *      server.  This is the authentication request as described in
+ *      the OpenID specification.
+ *   4. The identity server's site sends the browser a redirect back
+ *      to the consumer site.  This redirect contains the server's
+ *      response to the authentication request.
+ *
+ * The most important part of the flow to note is the consumer's site
+ * must handle two separate HTTP requests in order to perform the full
+ * identity check.
+ *
+ * LIBRARY DESIGN
+ * 
+ * This consumer library is designed with that flow in mind.  The goal
+ * is to make it as easy as possible to perform the above steps
+ * securely.
+ *
+ * At a high level, there are two important parts in the consumer
+ * library.  The first important part is this module, which contains
+ * the interface to actually use this library.  The second is the
+ * Auth_OpenID_Interface class, which describes the interface to use
+ * if you need to create a custom method for storing the state this
+ * library needs to maintain between requests.
+ *
+ * In general, the second part is less important for users of the
+ * library to know about, as several implementations are provided
+ * which cover a wide variety of situations in which consumers may use
+ * the library.
+ *
+ * This module contains a class, Auth_OpenID_Consumer, with methods
+ * corresponding to the actions necessary in each of steps 2, 3, and 4
+ * described in the overview.  Use of this library should be as easy
+ * as creating an Auth_OpenID_Consumer instance and calling the
+ * methods appropriate for the action the site wants to take.
+ *
+ * STORES AND DUMB MODE
+ *
+ * OpenID is a protocol that works best when the consumer site is able
+ * to store some state.  This is the normal mode of operation for the
+ * protocol, and is sometimes referred to as smart mode.  There is
+ * also a fallback mode, known as dumb mode, which is available when
+ * the consumer site is not able to store state.  This mode should be
+ * avoided when possible, as it leaves the implementation more
+ * vulnerable to replay attacks.
+ *
+ * The mode the library works in for normal operation is determined by
+ * the store that it is given.  The store is an abstraction that
+ * handles the data that the consumer needs to manage between http
+ * requests in order to operate efficiently and securely.
+ *
+ * Several store implementation are provided, and the interface is
+ * fully documented so that custom stores can be used as well.  See
+ * the documentation for the Auth_OpenID_Consumer class for more
+ * information on the interface for stores.  The implementations that
+ * are provided allow the consumer site to store the necessary data in
+ * several different ways, including several SQL databases and normal
+ * files on disk.
+ *
+ * There is an additional concrete store provided that puts the system
+ * in dumb mode.  This is not recommended, as it removes the library's
+ * ability to stop replay attacks reliably.  It still uses time-based
+ * checking to make replay attacks only possible within a small
+ * window, but they remain possible within that window.  This store
+ * should only be used if the consumer site has no way to retain data
+ * between requests at all.
+ *
+ * IMMEDIATE MODE
+ *
+ * In the flow described above, the user may need to confirm to the
+ * lidentity server that it's ok to authorize his or her identity.
+ * The server may draw pages asking for information from the user
+ * before it redirects the browser back to the consumer's site.  This
+ * is generally transparent to the consumer site, so it is typically
+ * ignored as an implementation detail.
+ *
+ * There can be times, however, where the consumer site wants to get a
+ * response immediately.  When this is the case, the consumer can put
+ * the library in immediate mode.  In immediate mode, there is an
+ * extra response possible from the server, which is essentially the
+ * server reporting that it doesn't have enough information to answer
+ * the question yet.
+ *
+ * USING THIS LIBRARY
+ *
+ * Integrating this library into an application is usually a
+ * relatively straightforward process.  The process should basically
+ * follow this plan:
+ *
+ * Add an OpenID login field somewhere on your site.  When an OpenID
+ * is entered in that field and the form is submitted, it should make
+ * a request to the your site which includes that OpenID URL.
+ *
+ * First, the application should instantiate the Auth_OpenID_Consumer
+ * class using the store of choice (Auth_OpenID_FileStore or one of
+ * the SQL-based stores).  If the application has a custom
+ * session-management implementation, an object implementing the
+ * {@link Auth_Yadis_PHPSession} interface should be passed as the
+ * second parameter.  Otherwise, the default uses $_SESSION.
+ *
+ * Next, the application should call the Auth_OpenID_Consumer object's
+ * 'begin' method.  This method takes the OpenID URL.  The 'begin'
+ * method returns an Auth_OpenID_AuthRequest object.
+ *
+ * Next, the application should call the 'redirectURL' method of the
+ * Auth_OpenID_AuthRequest object.  The 'return_to' URL parameter is
+ * the URL that the OpenID server will send the user back to after
+ * attempting to verify his or her identity.  The 'trust_root' is the
+ * URL (or URL pattern) that identifies your web site to the user when
+ * he or she is authorizing it.  Send a redirect to the resulting URL
+ * to the user's browser.
+ *
+ * That's the first half of the authentication process.  The second
+ * half of the process is done after the user's ID server sends the
+ * user's browser a redirect back to your site to complete their
+ * login.
+ *
+ * When that happens, the user will contact your site at the URL given
+ * as the 'return_to' URL to the Auth_OpenID_AuthRequest::redirectURL
+ * call made above.  The request will have several query parameters
+ * added to the URL by the identity server as the information
+ * necessary to finish the request.
+ *
+ * Lastly, instantiate an Auth_OpenID_Consumer instance as above and
+ * call its 'complete' method, passing in all the received query
+ * arguments.
+ *
+ * There are multiple possible return types possible from that
+ * method. These indicate the whether or not the login was successful,
+ * and include any additional information appropriate for their type.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Require utility classes and functions for the consumer.
+ */
+require_once "Auth/OpenID.php";
+require_once "Auth/OpenID/Message.php";
+require_once "Auth/OpenID/HMAC.php";
+require_once "Auth/OpenID/Association.php";
+require_once "Auth/OpenID/CryptUtil.php";
+require_once "Auth/OpenID/DiffieHellman.php";
+require_once "Auth/OpenID/KVForm.php";
+require_once "Auth/OpenID/Nonce.php";
+require_once "Auth/OpenID/Discover.php";
+require_once "Auth/OpenID/URINorm.php";
+require_once "Auth/Yadis/Manager.php";
+require_once "Auth/Yadis/XRI.php";
+
+/**
+ * This is the status code returned when the complete method returns
+ * successfully.
+ */
+define('Auth_OpenID_SUCCESS', 'success');
+
+/**
+ * Status to indicate cancellation of OpenID authentication.
+ */
+define('Auth_OpenID_CANCEL', 'cancel');
+
+/**
+ * This is the status code completeAuth returns when the value it
+ * received indicated an invalid login.
+ */
+define('Auth_OpenID_FAILURE', 'failure');
+
+/**
+ * This is the status code completeAuth returns when the
+ * {@link Auth_OpenID_Consumer} instance is in immediate mode, and the
+ * identity server sends back a URL to send the user to to complete his
+ * or her login.
+ */
+define('Auth_OpenID_SETUP_NEEDED', 'setup needed');
+
+/**
+ * This is the status code beginAuth returns when the page fetched
+ * from the entered OpenID URL doesn't contain the necessary link tags
+ * to function as an identity page.
+ */
+define('Auth_OpenID_PARSE_ERROR', 'parse error');
+
+/**
+ * An OpenID consumer implementation that performs discovery and does
+ * session management.  See the Consumer.php file documentation for
+ * more information.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Consumer {
+
+    /**
+     * @access private
+     */
+    var $discoverMethod = 'Auth_OpenID_discover';
+
+    /**
+     * @access private
+     */
+    var $session_key_prefix = "_openid_consumer_";
+
+    /**
+     * @access private
+     */
+    var $_token_suffix = "last_token";
+
+    /**
+     * Initialize a Consumer instance.
+     *
+     * You should create a new instance of the Consumer object with
+     * every HTTP request that handles OpenID transactions.
+     *
+     * @param Auth_OpenID_OpenIDStore $store This must be an object
+     * that implements the interface in {@link
+     * Auth_OpenID_OpenIDStore}.  Several concrete implementations are
+     * provided, to cover most common use cases.  For stores backed by
+     * MySQL, PostgreSQL, or SQLite, see the {@link
+     * Auth_OpenID_SQLStore} class and its sublcasses.  For a
+     * filesystem-backed store, see the {@link Auth_OpenID_FileStore}
+     * module.  As a last resort, if it isn't possible for the server
+     * to store state at all, an instance of {@link
+     * Auth_OpenID_DumbStore} can be used.
+     *
+     * @param mixed $session An object which implements the interface
+     * of the {@link Auth_Yadis_PHPSession} class.  Particularly, this
+     * object is expected to have these methods: get($key), set($key),
+     * $value), and del($key).  This defaults to a session object
+     * which wraps PHP's native session machinery.  You should only
+     * need to pass something here if you have your own sessioning
+     * implementation.
+     *
+     * @param str $consumer_cls The name of the class to instantiate
+     * when creating the internal consumer object.  This is used for
+     * testing.
+     */
+    function Auth_OpenID_Consumer(&$store, $session = null,
+                                  $consumer_cls = null)
+    {
+        if ($session === null) {
+            $session = new Auth_Yadis_PHPSession();
+        }
+
+        $this->session =& $session;
+
+        if ($consumer_cls !== null) {
+            $this->consumer = new $consumer_cls($store);
+        } else {
+            $this->consumer = new Auth_OpenID_GenericConsumer($store);
+        }
+
+        $this->_token_key = $this->session_key_prefix . $this->_token_suffix;
+    }
+
+    /**
+     * Used in testing to define the discovery mechanism.
+     *
+     * @access private
+     */
+    function getDiscoveryObject(&$session, $openid_url,
+                                $session_key_prefix)
+    {
+        return new Auth_Yadis_Discovery($session, $openid_url,
+                                        $session_key_prefix);
+    }
+
+    /**
+     * Start the OpenID authentication process. See steps 1-2 in the
+     * overview at the top of this file.
+     *
+     * @param string $user_url Identity URL given by the user. This
+     * method performs a textual transformation of the URL to try and
+     * make sure it is normalized. For example, a user_url of
+     * example.com will be normalized to http://example.com/
+     * normalizing and resolving any redirects the server might issue.
+     *
+     * @param bool $anonymous True if the OpenID request is to be sent
+     * to the server without any identifier information.  Use this
+     * when you want to transport data but don't want to do OpenID
+     * authentication with identifiers.
+     *
+     * @return Auth_OpenID_AuthRequest $auth_request An object
+     * containing the discovered information will be returned, with a
+     * method for building a redirect URL to the server, as described
+     * in step 3 of the overview. This object may also be used to add
+     * extension arguments to the request, using its 'addExtensionArg'
+     * method.
+     */
+    function begin($user_url, $anonymous=false)
+    {
+        $openid_url = $user_url;
+
+        $disco = $this->getDiscoveryObject($this->session,
+                                           $openid_url,
+                                           $this->session_key_prefix);
+
+        // Set the 'stale' attribute of the manager.  If discovery
+        // fails in a fatal way, the stale flag will cause the manager
+        // to be cleaned up next time discovery is attempted.
+
+        $m = $disco->getManager();
+        $loader = new Auth_Yadis_ManagerLoader();
+
+        if ($m) {
+            if ($m->stale) {
+                $disco->destroyManager();
+            } else {
+                $m->stale = true;
+                $disco->session->set($disco->session_key,
+                                     serialize($loader->toSession($m)));
+            }
+        }
+
+        $endpoint = $disco->getNextService($this->discoverMethod,
+                                           $this->consumer->fetcher);
+
+        // Reset the 'stale' attribute of the manager.
+        $m =& $disco->getManager();
+        if ($m) {
+            $m->stale = false;
+            $disco->session->set($disco->session_key,
+                                 serialize($loader->toSession($m)));
+        }
+
+        if ($endpoint === null) {
+            return null;
+        } else {
+            return $this->beginWithoutDiscovery($endpoint,
+                                                $anonymous);
+        }
+    }
+
+    /**
+     * Start OpenID verification without doing OpenID server
+     * discovery. This method is used internally by Consumer.begin
+     * after discovery is performed, and exists to provide an
+     * interface for library users needing to perform their own
+     * discovery.
+     *
+     * @param Auth_OpenID_ServiceEndpoint $endpoint an OpenID service
+     * endpoint descriptor.
+     *
+     * @param bool anonymous Set to true if you want to perform OpenID
+     * without identifiers.
+     *
+     * @return Auth_OpenID_AuthRequest $auth_request An OpenID
+     * authentication request object.
+     */
+    function &beginWithoutDiscovery($endpoint, $anonymous=false)
+    {
+        $loader = new Auth_OpenID_ServiceEndpointLoader();
+        $auth_req = $this->consumer->begin($endpoint);
+        $this->session->set($this->_token_key,
+              $loader->toSession($auth_req->endpoint));
+        if (!$auth_req->setAnonymous($anonymous)) {
+            return new Auth_OpenID_FailureResponse(null,
+              "OpenID 1 requests MUST include the identifier " .
+              "in the request.");
+        }
+        return $auth_req;
+    }
+
+    /**
+     * Called to interpret the server's response to an OpenID
+     * request. It is called in step 4 of the flow described in the
+     * consumer overview.
+     *
+     * @param string $current_url The URL used to invoke the application.
+     * Extract the URL from your application's web
+     * request framework and specify it here to have it checked
+     * against the openid.current_url value in the response.  If
+     * the current_url URL check fails, the status of the
+     * completion will be FAILURE.
+     *
+     * @param array $query An array of the query parameters (key =>
+     * value pairs) for this HTTP request.  Defaults to null.  If
+     * null, the GET or POST data are automatically gotten from the
+     * PHP environment.  It is only useful to override $query for
+     * testing.
+     *
+     * @return Auth_OpenID_ConsumerResponse $response A instance of an
+     * Auth_OpenID_ConsumerResponse subclass. The type of response is
+     * indicated by the status attribute, which will be one of
+     * SUCCESS, CANCEL, FAILURE, or SETUP_NEEDED.
+     */
+    function complete($current_url, $query=null)
+    {
+        if ($current_url && !is_string($current_url)) {
+            // This is ugly, but we need to complain loudly when
+            // someone uses the API incorrectly.
+            trigger_error("current_url must be a string; see NEWS file " .
+                          "for upgrading notes.",
+                          E_USER_ERROR);
+        }
+
+        if ($query === null) {
+            $query = Auth_OpenID::getQuery();
+        }
+
+        $loader = new Auth_OpenID_ServiceEndpointLoader();
+        $endpoint_data = $this->session->get($this->_token_key);
+        $endpoint =
+            $loader->fromSession($endpoint_data);
+
+        $message = Auth_OpenID_Message::fromPostArgs($query);
+        $response = $this->consumer->complete($message, $endpoint, 
+                                              $current_url);
+        $this->session->del($this->_token_key);
+
+        if (in_array($response->status, array(Auth_OpenID_SUCCESS,
+                                              Auth_OpenID_CANCEL))) {
+            if ($response->identity_url !== null) {
+                $disco = $this->getDiscoveryObject($this->session,
+                                                   $response->identity_url,
+                                                   $this->session_key_prefix);
+                $disco->cleanup(true);
+            }
+        }
+
+        return $response;
+    }
+}
+
+/**
+ * A class implementing HMAC/DH-SHA1 consumer sessions.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_DiffieHellmanSHA1ConsumerSession {
+    var $session_type = 'DH-SHA1';
+    var $hash_func = 'Auth_OpenID_SHA1';
+    var $secret_size = 20;
+    var $allowed_assoc_types = array('HMAC-SHA1');
+
+    function Auth_OpenID_DiffieHellmanSHA1ConsumerSession($dh = null)
+    {
+        if ($dh === null) {
+            $dh = new Auth_OpenID_DiffieHellman();
+        }
+
+        $this->dh = $dh;
+    }
+
+    function getRequest()
+    {
+        $math =& Auth_OpenID_getMathLib();
+
+        $cpub = $math->longToBase64($this->dh->public);
+
+        $args = array('dh_consumer_public' => $cpub);
+
+        if (!$this->dh->usingDefaultValues()) {
+            $args = array_merge($args, array(
+                'dh_modulus' =>
+                     $math->longToBase64($this->dh->mod),
+                'dh_gen' =>
+                     $math->longToBase64($this->dh->gen)));
+        }
+
+        return $args;
+    }
+
+    function extractSecret($response)
+    {
+        if (!$response->hasKey(Auth_OpenID_OPENID_NS,
+                               'dh_server_public')) {
+            return null;
+        }
+
+        if (!$response->hasKey(Auth_OpenID_OPENID_NS,
+                               'enc_mac_key')) {
+            return null;
+        }
+
+        $math =& Auth_OpenID_getMathLib();
+
+        $spub = $math->base64ToLong($response->getArg(Auth_OpenID_OPENID_NS,
+                                                      'dh_server_public'));
+        $enc_mac_key = base64_decode($response->getArg(Auth_OpenID_OPENID_NS,
+                                                       'enc_mac_key'));
+
+        return $this->dh->xorSecret($spub, $enc_mac_key, $this->hash_func);
+    }
+}
+
+/**
+ * A class implementing HMAC/DH-SHA256 consumer sessions.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_DiffieHellmanSHA256ConsumerSession extends
+      Auth_OpenID_DiffieHellmanSHA1ConsumerSession {
+    var $session_type = 'DH-SHA256';
+    var $hash_func = 'Auth_OpenID_SHA256';
+    var $secret_size = 32;
+    var $allowed_assoc_types = array('HMAC-SHA256');
+}
+
+/**
+ * A class implementing plaintext consumer sessions.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_PlainTextConsumerSession {
+    var $session_type = 'no-encryption';
+    var $allowed_assoc_types =  array('HMAC-SHA1', 'HMAC-SHA256');
+
+    function getRequest()
+    {
+        return array();
+    }
+
+    function extractSecret($response)
+    {
+        if (!$response->hasKey(Auth_OpenID_OPENID_NS, 'mac_key')) {
+            return null;
+        }
+
+        return base64_decode($response->getArg(Auth_OpenID_OPENID_NS,
+                                               'mac_key'));
+    }
+}
+
+/**
+ * Returns available session types.
+ */
+function Auth_OpenID_getAvailableSessionTypes()
+{
+    $types = array(
+      'no-encryption' => 'Auth_OpenID_PlainTextConsumerSession',
+      'DH-SHA1' => 'Auth_OpenID_DiffieHellmanSHA1ConsumerSession',
+      'DH-SHA256' => 'Auth_OpenID_DiffieHellmanSHA256ConsumerSession');
+
+    return $types;
+}
+
+/**
+ * This class is the interface to the OpenID consumer logic.
+ * Instances of it maintain no per-request state, so they can be
+ * reused (or even used by multiple threads concurrently) as needed.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_GenericConsumer {
+    /**
+     * @access private
+     */
+    var $discoverMethod = 'Auth_OpenID_discover';
+
+    /**
+     * This consumer's store object.
+     */
+    var $store;
+
+    /**
+     * @access private
+     */
+    var $_use_assocs;
+
+    /**
+     * @access private
+     */
+    var $openid1_nonce_query_arg_name = 'janrain_nonce';
+
+    /**
+     * Another query parameter that gets added to the return_to for
+     * OpenID 1; if the user's session state is lost, use this claimed
+     * identifier to do discovery when verifying the response.
+     */
+    var $openid1_return_to_identifier_name = 'openid1_claimed_id';
+
+    /**
+     * This method initializes a new {@link Auth_OpenID_Consumer}
+     * instance to access the library.
+     *
+     * @param Auth_OpenID_OpenIDStore $store This must be an object
+     * that implements the interface in {@link Auth_OpenID_OpenIDStore}.
+     * Several concrete implementations are provided, to cover most common use
+     * cases.  For stores backed by MySQL, PostgreSQL, or SQLite, see
+     * the {@link Auth_OpenID_SQLStore} class and its sublcasses.  For a
+     * filesystem-backed store, see the {@link Auth_OpenID_FileStore} module.
+     * As a last resort, if it isn't possible for the server to store
+     * state at all, an instance of {@link Auth_OpenID_DumbStore} can be used.
+     *
+     * @param bool $immediate This is an optional boolean value.  It
+     * controls whether the library uses immediate mode, as explained
+     * in the module description.  The default value is False, which
+     * disables immediate mode.
+     */
+    function Auth_OpenID_GenericConsumer(&$store)
+    {
+        $this->store =& $store;
+        $this->negotiator =& Auth_OpenID_getDefaultNegotiator();
+        $this->_use_assocs = ($this->store ? true : false);
+
+        $this->fetcher = Auth_Yadis_Yadis::getHTTPFetcher();
+
+        $this->session_types = Auth_OpenID_getAvailableSessionTypes();
+    }
+
+    /**
+     * Called to begin OpenID authentication using the specified
+     * {@link Auth_OpenID_ServiceEndpoint}.
+     *
+     * @access private
+     */
+    function begin($service_endpoint)
+    {
+        $assoc = $this->_getAssociation($service_endpoint);
+        $r = new Auth_OpenID_AuthRequest($service_endpoint, $assoc);
+        $r->return_to_args[$this->openid1_nonce_query_arg_name] =
+            Auth_OpenID_mkNonce();
+
+        if ($r->message->isOpenID1()) {
+            $r->return_to_args[$this->openid1_return_to_identifier_name] =
+                $r->endpoint->claimed_id;
+        }
+
+        return $r;
+    }
+
+    /**
+     * Given an {@link Auth_OpenID_Message}, {@link
+     * Auth_OpenID_ServiceEndpoint} and optional return_to URL,
+     * complete OpenID authentication.
+     *
+     * @access private
+     */
+    function complete($message, $endpoint, $return_to)
+    {
+        $mode = $message->getArg(Auth_OpenID_OPENID_NS, 'mode',
+                                 '<no mode set>');
+
+        $mode_methods = array(
+                              'cancel' => '_complete_cancel',
+                              'error' => '_complete_error',
+                              'setup_needed' => '_complete_setup_needed',
+                              'id_res' => '_complete_id_res',
+                              );
+
+        $method = Auth_OpenID::arrayGet($mode_methods, $mode,
+                                        '_completeInvalid');
+
+        return call_user_func_array(array(&$this, $method),
+                                    array($message, &$endpoint, $return_to));
+    }
+
+    /**
+     * @access private
+     */
+    function _completeInvalid($message, &$endpoint, $unused)
+    {
+        $mode = $message->getArg(Auth_OpenID_OPENID_NS, 'mode',
+                                 '<No mode set>');
+
+        return new Auth_OpenID_FailureResponse($endpoint,
+                    sprintf("Invalid openid.mode '%s'", $mode));
+    }
+
+    /**
+     * @access private
+     */
+    function _complete_cancel($message, &$endpoint, $unused)
+    {
+        return new Auth_OpenID_CancelResponse($endpoint);
+    }
+
+    /**
+     * @access private
+     */
+    function _complete_error($message, &$endpoint, $unused)
+    {
+        $error = $message->getArg(Auth_OpenID_OPENID_NS, 'error');
+        $contact = $message->getArg(Auth_OpenID_OPENID_NS, 'contact');
+        $reference = $message->getArg(Auth_OpenID_OPENID_NS, 'reference');
+
+        return new Auth_OpenID_FailureResponse($endpoint, $error,
+                                               $contact, $reference);
+    }
+
+    /**
+     * @access private
+     */
+    function _complete_setup_needed($message, &$endpoint, $unused)
+    {
+        if (!$message->isOpenID2()) {
+            return $this->_completeInvalid($message, $endpoint);
+        }
+
+        $user_setup_url = $message->getArg(Auth_OpenID_OPENID2_NS,
+                                           'user_setup_url');
+        return new Auth_OpenID_SetupNeededResponse($endpoint, $user_setup_url);
+    }
+
+    /**
+     * @access private
+     */
+    function _complete_id_res($message, &$endpoint, $return_to)
+    {
+        $user_setup_url = $message->getArg(Auth_OpenID_OPENID1_NS,
+                                           'user_setup_url');
+
+        if ($this->_checkSetupNeeded($message)) {
+            return new Auth_OpenID_SetupNeededResponse(
+                $endpoint, $user_setup_url);
+        } else {
+            return $this->_doIdRes($message, $endpoint, $return_to);
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _checkSetupNeeded($message)
+    {
+        // In OpenID 1, we check to see if this is a cancel from
+        // immediate mode by the presence of the user_setup_url
+        // parameter.
+        if ($message->isOpenID1()) {
+            $user_setup_url = $message->getArg(Auth_OpenID_OPENID1_NS,
+                                               'user_setup_url');
+            if ($user_setup_url !== null) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * @access private
+     */
+    function _doIdRes($message, $endpoint, $return_to)
+    {
+        // Checks for presence of appropriate fields (and checks
+        // signed list fields)
+        $result = $this->_idResCheckForFields($message);
+
+        if (Auth_OpenID::isFailure($result)) {
+            return $result;
+        }
+
+        if (!$this->_checkReturnTo($message, $return_to)) {
+            return new Auth_OpenID_FailureResponse(null,
+            sprintf("return_to does not match return URL. Expected %s, got %s",
+                    $return_to,
+                    $message->getArg(Auth_OpenID_OPENID_NS, 'return_to')));
+        }
+
+        // Verify discovery information:
+        $result = $this->_verifyDiscoveryResults($message, $endpoint);
+
+        if (Auth_OpenID::isFailure($result)) {
+            return $result;
+        }
+
+        $endpoint = $result;
+
+        $result = $this->_idResCheckSignature($message,
+                                              $endpoint->server_url);
+
+        if (Auth_OpenID::isFailure($result)) {
+            return $result;
+        }
+
+        $result = $this->_idResCheckNonce($message, $endpoint);
+
+        if (Auth_OpenID::isFailure($result)) {
+            return $result;
+        }
+
+        $signed_list_str = $message->getArg(Auth_OpenID_OPENID_NS, 'signed',
+                                            Auth_OpenID_NO_DEFAULT);
+        if (Auth_OpenID::isFailure($signed_list_str)) {
+            return $signed_list_str;
+        }
+        $signed_list = explode(',', $signed_list_str);
+
+        $signed_fields = Auth_OpenID::addPrefix($signed_list, "openid.");
+
+        return new Auth_OpenID_SuccessResponse($endpoint, $message,
+                                               $signed_fields);
+
+    }
+
+    /**
+     * @access private
+     */
+    function _checkReturnTo($message, $return_to)
+    {
+        // Check an OpenID message and its openid.return_to value
+        // against a return_to URL from an application.  Return True
+        // on success, False on failure.
+
+        // Check the openid.return_to args against args in the
+        // original message.
+        $result = Auth_OpenID_GenericConsumer::_verifyReturnToArgs(
+                                           $message->toPostArgs());
+        if (Auth_OpenID::isFailure($result)) {
+            return false;
+        }
+
+        // Check the return_to base URL against the one in the
+        // message.
+        $msg_return_to = $message->getArg(Auth_OpenID_OPENID_NS,
+                                          'return_to');
+        if (Auth_OpenID::isFailure($return_to)) {
+            // XXX log me
+            return false;
+        }
+
+        $return_to_parts = parse_url(Auth_OpenID_urinorm($return_to));
+        $msg_return_to_parts = parse_url(Auth_OpenID_urinorm($msg_return_to));
+
+        // If port is absent from both, add it so it's equal in the
+        // check below.
+        if ((!array_key_exists('port', $return_to_parts)) &&
+            (!array_key_exists('port', $msg_return_to_parts))) {
+            $return_to_parts['port'] = null;
+            $msg_return_to_parts['port'] = null;
+        }
+
+        // If path is absent from both, add it so it's equal in the
+        // check below.
+        if ((!array_key_exists('path', $return_to_parts)) &&
+            (!array_key_exists('path', $msg_return_to_parts))) {
+            $return_to_parts['path'] = null;
+            $msg_return_to_parts['path'] = null;
+        }
+
+        // The URL scheme, authority, and path MUST be the same
+        // between the two URLs.
+        foreach (array('scheme', 'host', 'port', 'path') as $component) {
+            // If the url component is absent in either URL, fail.
+            // There should always be a scheme, host, port, and path.
+            if (!array_key_exists($component, $return_to_parts)) {
+                return false;
+            }
+
+            if (!array_key_exists($component, $msg_return_to_parts)) {
+                return false;
+            }
+
+            if (Auth_OpenID::arrayGet($return_to_parts, $component) !==
+                Auth_OpenID::arrayGet($msg_return_to_parts, $component)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * @access private
+     */
+    function _verifyReturnToArgs($query)
+    {
+        // Verify that the arguments in the return_to URL are present in this
+        // response.
+
+        $message = Auth_OpenID_Message::fromPostArgs($query);
+        $return_to = $message->getArg(Auth_OpenID_OPENID_NS, 'return_to');
+
+        if (Auth_OpenID::isFailure($return_to)) {
+            return $return_to;
+        }
+        // XXX: this should be checked by _idResCheckForFields
+        if (!$return_to) {
+            return new Auth_OpenID_FailureResponse(null,
+                           "Response has no return_to");
+        }
+
+        $parsed_url = parse_url($return_to);
+
+        $q = array();
+        if (array_key_exists('query', $parsed_url)) {
+            $rt_query = $parsed_url['query'];
+            $q = Auth_OpenID::parse_str($rt_query);
+        }
+
+        foreach ($q as $rt_key => $rt_value) {
+            if (!array_key_exists($rt_key, $query)) {
+                return new Auth_OpenID_FailureResponse(null,
+                  sprintf("return_to parameter %s absent from query", $rt_key));
+            } else {
+                $value = $query[$rt_key];
+                if ($rt_value != $value) {
+                    return new Auth_OpenID_FailureResponse(null,
+                      sprintf("parameter %s value %s does not match " .
+                              "return_to value %s", $rt_key,
+                              $value, $rt_value));
+                }
+            }
+        }
+
+        // Make sure all non-OpenID arguments in the response are also
+        // in the signed return_to.
+        $bare_args = $message->getArgs(Auth_OpenID_BARE_NS);
+        foreach ($bare_args as $key => $value) {
+            if (Auth_OpenID::arrayGet($q, $key) != $value) {
+                return new Auth_OpenID_FailureResponse(null,
+                  sprintf("Parameter %s = %s not in return_to URL",
+                          $key, $value));
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * @access private
+     */
+    function _idResCheckSignature($message, $server_url)
+    {
+        $assoc_handle = $message->getArg(Auth_OpenID_OPENID_NS,
+                                         'assoc_handle');
+        if (Auth_OpenID::isFailure($assoc_handle)) {
+            return $assoc_handle;
+        }
+
+        $assoc = $this->store->getAssociation($server_url, $assoc_handle);
+
+        if ($assoc) {
+            if ($assoc->getExpiresIn() <= 0) {
+                // XXX: It might be a good idea sometimes to re-start
+                // the authentication with a new association. Doing it
+                // automatically opens the possibility for
+                // denial-of-service by a server that just returns
+                // expired associations (or really short-lived
+                // associations)
+                return new Auth_OpenID_FailureResponse(null,
+                             'Association with ' . $server_url . ' expired');
+            }
+
+            if (!$assoc->checkMessageSignature($message)) {
+                return new Auth_OpenID_FailureResponse(null,
+                                                       "Bad signature");
+            }
+        } else {
+            // It's not an association we know about.  Stateless mode
+            // is our only possible path for recovery.  XXX - async
+            // framework will not want to block on this call to
+            // _checkAuth.
+            if (!$this->_checkAuth($message, $server_url)) {
+                return new Auth_OpenID_FailureResponse(null,
+                             "Server denied check_authentication");
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * @access private
+     */
+    function _verifyDiscoveryResults($message, $endpoint=null)
+    {
+        if ($message->getOpenIDNamespace() == Auth_OpenID_OPENID2_NS) {
+            return $this->_verifyDiscoveryResultsOpenID2($message,
+                                                         $endpoint);
+        } else {
+            return $this->_verifyDiscoveryResultsOpenID1($message,
+                                                         $endpoint);
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _verifyDiscoveryResultsOpenID1($message, $endpoint)
+    {
+        $claimed_id = $message->getArg(Auth_OpenID_BARE_NS,
+                                $this->openid1_return_to_identifier_name);
+
+        if (($endpoint === null) && ($claimed_id === null)) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+              'When using OpenID 1, the claimed ID must be supplied, ' .
+              'either by passing it through as a return_to parameter ' .
+              'or by using a session, and supplied to the GenericConsumer ' .
+              'as the argument to complete()');
+        } else if (($endpoint !== null) && ($claimed_id === null)) {
+            $claimed_id = $endpoint->claimed_id;
+        }
+
+        $to_match = new Auth_OpenID_ServiceEndpoint();
+        $to_match->type_uris = array(Auth_OpenID_TYPE_1_1);
+        $to_match->local_id = $message->getArg(Auth_OpenID_OPENID1_NS,
+                                               'identity');
+
+        // Restore delegate information from the initiation phase
+        $to_match->claimed_id = $claimed_id;
+
+        if ($to_match->local_id === null) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+                         "Missing required field openid.identity");
+        }
+
+        $to_match_1_0 = $to_match->copy();
+        $to_match_1_0->type_uris = array(Auth_OpenID_TYPE_1_0);
+
+        if ($endpoint !== null) {
+            $result = $this->_verifyDiscoverySingle($endpoint, $to_match);
+
+            if (is_a($result, 'Auth_OpenID_TypeURIMismatch')) {
+                $result = $this->_verifyDiscoverySingle($endpoint,
+                                                        $to_match_1_0);
+            }
+
+            if (Auth_OpenID::isFailure($result)) {
+                // oidutil.log("Error attempting to use stored
+                //             discovery information: " + str(e))
+                //             oidutil.log("Attempting discovery to
+                //             verify endpoint")
+            } else {
+                return $endpoint;
+            }
+        }
+
+        // Endpoint is either bad (failed verification) or None
+        return $this->_discoverAndVerify($to_match->claimed_id,
+                                         array($to_match, $to_match_1_0));
+    }
+
+    /**
+     * @access private
+     */
+    function _verifyDiscoverySingle($endpoint, $to_match)
+    {
+        // Every type URI that's in the to_match endpoint has to be
+        // present in the discovered endpoint.
+        foreach ($to_match->type_uris as $type_uri) {
+            if (!$endpoint->usesExtension($type_uri)) {
+                return new Auth_OpenID_TypeURIMismatch($endpoint,
+                             "Required type ".$type_uri." not present");
+            }
+        }
+
+        // Fragments do not influence discovery, so we can't compare a
+        // claimed identifier with a fragment to discovered
+        // information.
+        list($defragged_claimed_id, $_) =
+            Auth_OpenID::urldefrag($to_match->claimed_id);
+
+        if ($defragged_claimed_id != $endpoint->claimed_id) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+              sprintf('Claimed ID does not match (different subjects!), ' .
+                      'Expected %s, got %s', $defragged_claimed_id,
+                      $endpoint->claimed_id));
+        }
+
+        if ($to_match->getLocalID() != $endpoint->getLocalID()) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+              sprintf('local_id mismatch. Expected %s, got %s',
+                      $to_match->getLocalID(), $endpoint->getLocalID()));
+        }
+
+        // If the server URL is None, this must be an OpenID 1
+        // response, because op_endpoint is a required parameter in
+        // OpenID 2. In that case, we don't actually care what the
+        // discovered server_url is, because signature checking or
+        // check_auth should take care of that check for us.
+        if ($to_match->server_url === null) {
+            if ($to_match->preferredNamespace() != Auth_OpenID_OPENID1_NS) {
+                return new Auth_OpenID_FailureResponse($endpoint,
+                             "Preferred namespace mismatch (bug)");
+            }
+        } else if ($to_match->server_url != $endpoint->server_url) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+              sprintf('OP Endpoint mismatch. Expected %s, got %s',
+                      $to_match->server_url, $endpoint->server_url));
+        }
+
+        return null;
+    }
+
+    /**
+     * @access private
+     */
+    function _verifyDiscoveryResultsOpenID2($message, $endpoint)
+    {
+        $to_match = new Auth_OpenID_ServiceEndpoint();
+        $to_match->type_uris = array(Auth_OpenID_TYPE_2_0);
+        $to_match->claimed_id = $message->getArg(Auth_OpenID_OPENID2_NS,
+                                                 'claimed_id');
+
+        $to_match->local_id = $message->getArg(Auth_OpenID_OPENID2_NS,
+                                                'identity');
+
+        $to_match->server_url = $message->getArg(Auth_OpenID_OPENID2_NS,
+                                                 'op_endpoint');
+
+        if ($to_match->server_url === null) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+                         "OP Endpoint URL missing");
+        }
+
+        // claimed_id and identifier must both be present or both be
+        // absent
+        if (($to_match->claimed_id === null) &&
+            ($to_match->local_id !== null)) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+              'openid.identity is present without openid.claimed_id');
+        }
+
+        if (($to_match->claimed_id !== null) &&
+            ($to_match->local_id === null)) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+              'openid.claimed_id is present without openid.identity');
+        }
+
+        if ($to_match->claimed_id === null) {
+            // This is a response without identifiers, so there's
+            // really no checking that we can do, so return an
+            // endpoint that's for the specified `openid.op_endpoint'
+            return Auth_OpenID_ServiceEndpoint::fromOPEndpointURL(
+                                                $to_match->server_url);
+        }
+
+        if (!$endpoint) {
+            // The claimed ID doesn't match, so we have to do
+            // discovery again. This covers not using sessions, OP
+            // identifier endpoints and responses that didn't match
+            // the original request.
+            // oidutil.log('No pre-discovered information supplied.')
+            return $this->_discoverAndVerify($to_match->claimed_id,
+                                             array($to_match));
+        } else {
+
+            // The claimed ID matches, so we use the endpoint that we
+            // discovered in initiation. This should be the most
+            // common case.
+            $result = $this->_verifyDiscoverySingle($endpoint, $to_match);
+
+            if (Auth_OpenID::isFailure($result)) {
+                $endpoint = $this->_discoverAndVerify($to_match->claimed_id,
+                                                      array($to_match));
+                if (Auth_OpenID::isFailure($endpoint)) {
+                    return $endpoint;
+                }
+            }
+        }
+
+        // The endpoint we return should have the claimed ID from the
+        // message we just verified, fragment and all.
+        if ($endpoint->claimed_id != $to_match->claimed_id) {
+            $endpoint->claimed_id = $to_match->claimed_id;
+        }
+
+        return $endpoint;
+    }
+
+    /**
+     * @access private
+     */
+    function _discoverAndVerify($claimed_id, $to_match_endpoints)
+    {
+        // oidutil.log('Performing discovery on %s' % (claimed_id,))
+        list($unused, $services) = call_user_func($this->discoverMethod,
+                                                  $claimed_id,
+                                                  &$this->fetcher);
+
+        if (!$services) {
+            return new Auth_OpenID_FailureResponse(null,
+              sprintf("No OpenID information found at %s",
+                      $claimed_id));
+        }
+
+        return $this->_verifyDiscoveryServices($claimed_id, $services,
+                                               $to_match_endpoints);
+    }
+
+    /**
+     * @access private
+     */
+    function _verifyDiscoveryServices($claimed_id, 
+                                      &$services, &$to_match_endpoints)
+    {
+        // Search the services resulting from discovery to find one
+        // that matches the information from the assertion
+
+        foreach ($services as $endpoint) {
+            foreach ($to_match_endpoints as $to_match_endpoint) {
+                $result = $this->_verifyDiscoverySingle($endpoint, 
+                                                        $to_match_endpoint);
+
+                if (!Auth_OpenID::isFailure($result)) {
+                    // It matches, so discover verification has
+                    // succeeded. Return this endpoint.
+                    return $endpoint;
+                }
+            }
+        }
+
+        return new Auth_OpenID_FailureResponse(null,
+          sprintf('No matching endpoint found after discovering %s',
+                  $claimed_id));
+    }
+
+    /**
+     * Extract the nonce from an OpenID 1 response.  Return the nonce
+     * from the BARE_NS since we independently check the return_to
+     * arguments are the same as those in the response message.
+     *
+     * See the openid1_nonce_query_arg_name class variable
+     *
+     * @returns $nonce The nonce as a string or null
+     *
+     * @access private
+     */
+    function _idResGetNonceOpenID1($message, $endpoint)
+    {
+        return $message->getArg(Auth_OpenID_BARE_NS,
+                                $this->openid1_nonce_query_arg_name);
+    }
+
+    /**
+     * @access private
+     */
+    function _idResCheckNonce($message, $endpoint)
+    {
+        if ($message->isOpenID1()) {
+            // This indicates that the nonce was generated by the consumer
+            $nonce = $this->_idResGetNonceOpenID1($message, $endpoint);
+            $server_url = '';
+        } else {
+            $nonce = $message->getArg(Auth_OpenID_OPENID2_NS,
+                                      'response_nonce');
+
+            $server_url = $endpoint->server_url;
+        }
+
+        if ($nonce === null) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+                                     "Nonce missing from response");
+        }
+
+        $parts = Auth_OpenID_splitNonce($nonce);
+
+        if ($parts === null) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+                                     "Malformed nonce in response");
+        }
+
+        list($timestamp, $salt) = $parts;
+
+        if (!$this->store->useNonce($server_url, $timestamp, $salt)) {
+            return new Auth_OpenID_FailureResponse($endpoint,
+                         "Nonce already used or out of range");
+        }
+
+        return null;
+    }
+
+    /**
+     * @access private
+     */
+    function _idResCheckForFields($message)
+    {
+        $basic_fields = array('return_to', 'assoc_handle', 'sig', 'signed');
+        $basic_sig_fields = array('return_to', 'identity');
+
+        $require_fields = array(
+            Auth_OpenID_OPENID2_NS => array_merge($basic_fields,
+                                                  array('op_endpoint')),
+
+            Auth_OpenID_OPENID1_NS => array_merge($basic_fields,
+                                                  array('identity'))
+            );
+
+        $require_sigs = array(
+            Auth_OpenID_OPENID2_NS => array_merge($basic_sig_fields,
+                                                  array('response_nonce',
+                                                        'claimed_id',
+                                                        'assoc_handle',
+                                                        'op_endpoint')),
+            Auth_OpenID_OPENID1_NS => array_merge($basic_sig_fields,
+                                                  array('nonce'))
+            );
+
+        foreach ($require_fields[$message->getOpenIDNamespace()] as $field) {
+            if (!$message->hasKey(Auth_OpenID_OPENID_NS, $field)) {
+                return new Auth_OpenID_FailureResponse(null,
+                             "Missing required field '".$field."'");
+            }
+        }
+
+        $signed_list_str = $message->getArg(Auth_OpenID_OPENID_NS,
+                                            'signed',
+                                            Auth_OpenID_NO_DEFAULT);
+        if (Auth_OpenID::isFailure($signed_list_str)) {
+            return $signed_list_str;
+        }
+        $signed_list = explode(',', $signed_list_str);
+
+        foreach ($require_sigs[$message->getOpenIDNamespace()] as $field) {
+            // Field is present and not in signed list
+            if ($message->hasKey(Auth_OpenID_OPENID_NS, $field) &&
+                (!in_array($field, $signed_list))) {
+                return new Auth_OpenID_FailureResponse(null,
+                             "'".$field."' not signed");
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * @access private
+     */
+    function _checkAuth($message, $server_url)
+    {
+        $request = $this->_createCheckAuthRequest($message);
+        if ($request === null) {
+            return false;
+        }
+
+        $resp_message = $this->_makeKVPost($request, $server_url);
+        if (($resp_message === null) ||
+            (is_a($resp_message, 'Auth_OpenID_ServerErrorContainer'))) {
+            return false;
+        }
+
+        return $this->_processCheckAuthResponse($resp_message, $server_url);
+    }
+
+    /**
+     * @access private
+     */
+    function _createCheckAuthRequest($message)
+    {
+        $signed = $message->getArg(Auth_OpenID_OPENID_NS, 'signed');
+        if ($signed) {
+            foreach (explode(',', $signed) as $k) {
+                $value = $message->getAliasedArg($k);
+                if ($value === null) {
+                    return null;
+                }
+            }
+        }
+        $ca_message = $message->copy();
+        $ca_message->setArg(Auth_OpenID_OPENID_NS, 'mode', 
+                            'check_authentication');
+        return $ca_message;
+    }
+
+    /**
+     * @access private
+     */
+    function _processCheckAuthResponse($response, $server_url)
+    {
+        $is_valid = $response->getArg(Auth_OpenID_OPENID_NS, 'is_valid',
+                                      'false');
+
+        $invalidate_handle = $response->getArg(Auth_OpenID_OPENID_NS,
+                                               'invalidate_handle');
+
+        if ($invalidate_handle !== null) {
+            $this->store->removeAssociation($server_url,
+                                            $invalidate_handle);
+        }
+
+        if ($is_valid == 'true') {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Adapt a POST response to a Message.
+     *
+     * @param $response Result of a POST to an OpenID endpoint.
+     *
+     * @access private
+     */
+    function _httpResponseToMessage($response, $server_url)
+    {
+        // Should this function be named Message.fromHTTPResponse instead?
+        $response_message = Auth_OpenID_Message::fromKVForm($response->body);
+
+        if ($response->status == 400) {
+            return Auth_OpenID_ServerErrorContainer::fromMessage(
+                        $response_message);
+        } else if ($response->status != 200 and $response->status != 206) {
+            return null;
+        }
+
+        return $response_message;
+    }
+
+    /**
+     * @access private
+     */
+    function _makeKVPost($message, $server_url)
+    {
+        $body = $message->toURLEncoded();
+        $resp = $this->fetcher->post($server_url, $body);
+
+        if ($resp === null) {
+            return null;
+        }
+
+        return $this->_httpResponseToMessage($resp, $server_url);
+    }
+
+    /**
+     * @access private
+     */
+    function _getAssociation($endpoint)
+    {
+        if (!$this->_use_assocs) {
+            return null;
+        }
+
+        $assoc = $this->store->getAssociation($endpoint->server_url);
+
+        if (($assoc === null) ||
+            ($assoc->getExpiresIn() <= 0)) {
+
+            $assoc = $this->_negotiateAssociation($endpoint);
+
+            if ($assoc !== null) {
+                $this->store->storeAssociation($endpoint->server_url,
+                                               $assoc);
+            }
+        }
+
+        return $assoc;
+    }
+
+    /**
+     * Handle ServerErrors resulting from association requests.
+     *
+     * @return $result If server replied with an C{unsupported-type}
+     * error, return a tuple of supported C{association_type},
+     * C{session_type}.  Otherwise logs the error and returns null.
+     *
+     * @access private
+     */
+    function _extractSupportedAssociationType(&$server_error, &$endpoint,
+                                              $assoc_type)
+    {
+        // Any error message whose code is not 'unsupported-type'
+        // should be considered a total failure.
+        if (($server_error->error_code != 'unsupported-type') ||
+            ($server_error->message->isOpenID1())) {
+            return null;
+        }
+
+        // The server didn't like the association/session type that we
+        // sent, and it sent us back a message that might tell us how
+        // to handle it.
+
+        // Extract the session_type and assoc_type from the error
+        // message
+        $assoc_type = $server_error->message->getArg(Auth_OpenID_OPENID_NS,
+                                                     'assoc_type');
+
+        $session_type = $server_error->message->getArg(Auth_OpenID_OPENID_NS,
+                                                       'session_type');
+
+        if (($assoc_type === null) || ($session_type === null)) {
+            return null;
+        } else if (!$this->negotiator->isAllowed($assoc_type,
+                                                 $session_type)) {
+            return null;
+        } else {
+          return array($assoc_type, $session_type);
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _negotiateAssociation($endpoint)
+    {
+        // Get our preferred session/association type from the negotiatior.
+        list($assoc_type, $session_type) = $this->negotiator->getAllowedType();
+
+        $assoc = $this->_requestAssociation(
+                           $endpoint, $assoc_type, $session_type);
+
+        if (Auth_OpenID::isFailure($assoc)) {
+            return null;
+        }
+
+        if (is_a($assoc, 'Auth_OpenID_ServerErrorContainer')) {
+            $why = $assoc;
+
+            $supportedTypes = $this->_extractSupportedAssociationType(
+                                     $why, $endpoint, $assoc_type);
+
+            if ($supportedTypes !== null) {
+                list($assoc_type, $session_type) = $supportedTypes;
+
+                // Attempt to create an association from the assoc_type
+                // and session_type that the server told us it
+                // supported.
+                $assoc = $this->_requestAssociation(
+                                   $endpoint, $assoc_type, $session_type);
+
+                if (is_a($assoc, 'Auth_OpenID_ServerErrorContainer')) {
+                    // Do not keep trying, since it rejected the
+                    // association type that it told us to use.
+                    // oidutil.log('Server %s refused its suggested association
+                    //             'type: session_type=%s, assoc_type=%s'
+                    //             % (endpoint.server_url, session_type,
+                    //                assoc_type))
+                    return null;
+                } else {
+                    return $assoc;
+                }
+            } else {
+                return null;
+            }
+        } else {
+            return $assoc;
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _requestAssociation($endpoint, $assoc_type, $session_type)
+    {
+        list($assoc_session, $args) = $this->_createAssociateRequest(
+                                      $endpoint, $assoc_type, $session_type);
+
+        $response_message = $this->_makeKVPost($args, $endpoint->server_url);
+
+        if ($response_message === null) {
+            // oidutil.log('openid.associate request failed: %s' % (why[0],))
+            return null;
+        } else if (is_a($response_message,
+                        'Auth_OpenID_ServerErrorContainer')) {
+            return $response_message;
+        }
+
+        return $this->_extractAssociation($response_message, $assoc_session);
+    }
+
+    /**
+     * @access private
+     */
+    function _extractAssociation(&$assoc_response, &$assoc_session)
+    {
+        // Extract the common fields from the response, raising an
+        // exception if they are not found
+        $assoc_type = $assoc_response->getArg(
+                         Auth_OpenID_OPENID_NS, 'assoc_type',
+                         Auth_OpenID_NO_DEFAULT);
+
+        if (Auth_OpenID::isFailure($assoc_type)) {
+            return $assoc_type;
+        }
+
+        $assoc_handle = $assoc_response->getArg(
+                           Auth_OpenID_OPENID_NS, 'assoc_handle',
+                           Auth_OpenID_NO_DEFAULT);
+
+        if (Auth_OpenID::isFailure($assoc_handle)) {
+            return $assoc_handle;
+        }
+
+        // expires_in is a base-10 string. The Python parsing will
+        // accept literals that have whitespace around them and will
+        // accept negative values. Neither of these are really in-spec,
+        // but we think it's OK to accept them.
+        $expires_in_str = $assoc_response->getArg(
+                             Auth_OpenID_OPENID_NS, 'expires_in',
+                             Auth_OpenID_NO_DEFAULT);
+
+        if (Auth_OpenID::isFailure($expires_in_str)) {
+            return $expires_in_str;
+        }
+
+        $expires_in = Auth_OpenID::intval($expires_in_str);
+        if ($expires_in === false) {
+            
+            $err = sprintf("Could not parse expires_in from association ".
+                           "response %s", print_r($assoc_response, true));
+            return new Auth_OpenID_FailureResponse(null, $err);
+        }
+
+        // OpenID 1 has funny association session behaviour.
+        if ($assoc_response->isOpenID1()) {
+            $session_type = $this->_getOpenID1SessionType($assoc_response);
+        } else {
+            $session_type = $assoc_response->getArg(
+                               Auth_OpenID_OPENID2_NS, 'session_type',
+                               Auth_OpenID_NO_DEFAULT);
+
+            if (Auth_OpenID::isFailure($session_type)) {
+                return $session_type;
+            }
+        }
+
+        // Session type mismatch
+        if ($assoc_session->session_type != $session_type) {
+            if ($assoc_response->isOpenID1() &&
+                ($session_type == 'no-encryption')) {
+                // In OpenID 1, any association request can result in
+                // a 'no-encryption' association response. Setting
+                // assoc_session to a new no-encryption session should
+                // make the rest of this function work properly for
+                // that case.
+                $assoc_session = new Auth_OpenID_PlainTextConsumerSession();
+            } else {
+                // Any other mismatch, regardless of protocol version
+                // results in the failure of the association session
+                // altogether.
+                return null;
+            }
+        }
+
+        // Make sure assoc_type is valid for session_type
+        if (!in_array($assoc_type, $assoc_session->allowed_assoc_types)) {
+            return null;
+        }
+
+        // Delegate to the association session to extract the secret
+        // from the response, however is appropriate for that session
+        // type.
+        $secret = $assoc_session->extractSecret($assoc_response);
+
+        if ($secret === null) {
+            return null;
+        }
+
+        return Auth_OpenID_Association::fromExpiresIn(
+                 $expires_in, $assoc_handle, $secret, $assoc_type);
+    }
+
+    /**
+     * @access private
+     */
+    function _createAssociateRequest($endpoint, $assoc_type, $session_type)
+    {
+        if (array_key_exists($session_type, $this->session_types)) {
+            $session_type_class = $this->session_types[$session_type];
+
+            if (is_callable($session_type_class)) {
+                $assoc_session = $session_type_class();
+            } else {
+                $assoc_session = new $session_type_class();
+            }
+        } else {
+            return null;
+        }
+
+        $args = array(
+            'mode' => 'associate',
+            'assoc_type' => $assoc_type);
+
+        if (!$endpoint->compatibilityMode()) {
+            $args['ns'] = Auth_OpenID_OPENID2_NS;
+        }
+
+        // Leave out the session type if we're in compatibility mode
+        // *and* it's no-encryption.
+        if ((!$endpoint->compatibilityMode()) ||
+            ($assoc_session->session_type != 'no-encryption')) {
+            $args['session_type'] = $assoc_session->session_type;
+        }
+
+        $args = array_merge($args, $assoc_session->getRequest());
+        $message = Auth_OpenID_Message::fromOpenIDArgs($args);
+        return array($assoc_session, $message);
+    }
+
+    /**
+     * Given an association response message, extract the OpenID 1.X
+     * session type.
+     *
+     * This function mostly takes care of the 'no-encryption' default
+     * behavior in OpenID 1.
+     *
+     * If the association type is plain-text, this function will
+     * return 'no-encryption'
+     *
+     * @access private
+     * @return $typ The association type for this message
+     */
+    function _getOpenID1SessionType($assoc_response)
+    {
+        // If it's an OpenID 1 message, allow session_type to default
+        // to None (which signifies "no-encryption")
+        $session_type = $assoc_response->getArg(Auth_OpenID_OPENID1_NS,
+                                                'session_type');
+
+        // Handle the differences between no-encryption association
+        // respones in OpenID 1 and 2:
+
+        // no-encryption is not really a valid session type for OpenID
+        // 1, but we'll accept it anyway, while issuing a warning.
+        if ($session_type == 'no-encryption') {
+            // oidutil.log('WARNING: OpenID server sent "no-encryption"'
+            //             'for OpenID 1.X')
+        } else if (($session_type == '') || ($session_type === null)) {
+            // Missing or empty session type is the way to flag a
+            // 'no-encryption' response. Change the session type to
+            // 'no-encryption' so that it can be handled in the same
+            // way as OpenID 2 'no-encryption' respones.
+            $session_type = 'no-encryption';
+        }
+
+        return $session_type;
+    }
+}
+
+/**
+ * This class represents an authentication request from a consumer to
+ * an OpenID server.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AuthRequest {
+
+    /**
+     * Initialize an authentication request with the specified token,
+     * association, and endpoint.
+     *
+     * Users of this library should not create instances of this
+     * class.  Instances of this class are created by the library when
+     * needed.
+     */
+    function Auth_OpenID_AuthRequest(&$endpoint, $assoc)
+    {
+        $this->assoc = $assoc;
+        $this->endpoint =& $endpoint;
+        $this->return_to_args = array();
+        $this->message = new Auth_OpenID_Message(
+            $endpoint->preferredNamespace());
+        $this->_anonymous = false;
+    }
+
+    /**
+     * Add an extension to this checkid request.
+     *
+     * $extension_request: An object that implements the extension
+     * request interface for adding arguments to an OpenID message.
+     */
+    function addExtension(&$extension_request)
+    {
+        $extension_request->toMessage($this->message);
+    }
+
+    /**
+     * Add an extension argument to this OpenID authentication
+     * request.
+     *
+     * Use caution when adding arguments, because they will be
+     * URL-escaped and appended to the redirect URL, which can easily
+     * get quite long.
+     *
+     * @param string $namespace The namespace for the extension. For
+     * example, the simple registration extension uses the namespace
+     * 'sreg'.
+     *
+     * @param string $key The key within the extension namespace. For
+     * example, the nickname field in the simple registration
+     * extension's key is 'nickname'.
+     *
+     * @param string $value The value to provide to the server for
+     * this argument.
+     */
+    function addExtensionArg($namespace, $key, $value)
+    {
+        return $this->message->setArg($namespace, $key, $value);
+    }
+
+    /**
+     * Set whether this request should be made anonymously. If a
+     * request is anonymous, the identifier will not be sent in the
+     * request. This is only useful if you are making another kind of
+     * request with an extension in this request.
+     *
+     * Anonymous requests are not allowed when the request is made
+     * with OpenID 1.
+     */
+    function setAnonymous($is_anonymous)
+    {
+        if ($is_anonymous && $this->message->isOpenID1()) {
+            return false;
+        } else {
+            $this->_anonymous = $is_anonymous;
+            return true;
+        }
+    }
+
+    /**
+     * Produce a {@link Auth_OpenID_Message} representing this
+     * request.
+     *
+     * @param string $realm The URL (or URL pattern) that identifies
+     * your web site to the user when she is authorizing it.
+     *
+     * @param string $return_to The URL that the OpenID provider will
+     * send the user back to after attempting to verify her identity.
+     *
+     * Not specifying a return_to URL means that the user will not be
+     * returned to the site issuing the request upon its completion.
+     *
+     * @param bool $immediate If true, the OpenID provider is to send
+     * back a response immediately, useful for behind-the-scenes
+     * authentication attempts.  Otherwise the OpenID provider may
+     * engage the user before providing a response.  This is the
+     * default case, as the user may need to provide credentials or
+     * approve the request before a positive response can be sent.
+     */
+    function getMessage($realm, $return_to=null, $immediate=false)
+    {
+        if ($return_to) {
+            $return_to = Auth_OpenID::appendArgs($return_to,
+                                                 $this->return_to_args);
+        } else if ($immediate) {
+            // raise ValueError(
+            //     '"return_to" is mandatory when
+            //using "checkid_immediate"')
+            return new Auth_OpenID_FailureResponse(null,
+              "'return_to' is mandatory when using checkid_immediate");
+        } else if ($this->message->isOpenID1()) {
+            // raise ValueError('"return_to" is
+            // mandatory for OpenID 1 requests')
+            return new Auth_OpenID_FailureResponse(null,
+              "'return_to' is mandatory for OpenID 1 requests");
+        } else if ($this->return_to_args) {
+            // raise ValueError('extra "return_to" arguments
+            // were specified, but no return_to was specified')
+            return new Auth_OpenID_FailureResponse(null,
+              "extra 'return_to' arguments where specified, " .
+              "but no return_to was specified");
+        }
+
+        if ($immediate) {
+            $mode = 'checkid_immediate';
+        } else {
+            $mode = 'checkid_setup';
+        }
+
+        $message = $this->message->copy();
+        if ($message->isOpenID1()) {
+            $realm_key = 'trust_root';
+        } else {
+            $realm_key = 'realm';
+        }
+
+        $message->updateArgs(Auth_OpenID_OPENID_NS,
+                             array(
+                                   $realm_key => $realm,
+                                   'mode' => $mode,
+                                   'return_to' => $return_to));
+
+        if (!$this->_anonymous) {
+            if ($this->endpoint->isOPIdentifier()) {
+                // This will never happen when we're in compatibility
+                // mode, as long as isOPIdentifier() returns False
+                // whenever preferredNamespace() returns OPENID1_NS.
+                $claimed_id = $request_identity =
+                    Auth_OpenID_IDENTIFIER_SELECT;
+            } else {
+                $request_identity = $this->endpoint->getLocalID();
+                $claimed_id = $this->endpoint->claimed_id;
+            }
+
+            // This is true for both OpenID 1 and 2
+            $message->setArg(Auth_OpenID_OPENID_NS, 'identity',
+                             $request_identity);
+
+            if ($message->isOpenID2()) {
+                $message->setArg(Auth_OpenID_OPENID2_NS, 'claimed_id',
+                                 $claimed_id);
+            }
+        }
+
+        if ($this->assoc) {
+            $message->setArg(Auth_OpenID_OPENID_NS, 'assoc_handle',
+                             $this->assoc->handle);
+        }
+
+        return $message;
+    }
+
+    function redirectURL($realm, $return_to = null,
+                         $immediate = false)
+    {
+        $message = $this->getMessage($realm, $return_to, $immediate);
+
+        if (Auth_OpenID::isFailure($message)) {
+            return $message;
+        }
+
+        return $message->toURL($this->endpoint->server_url);
+    }
+
+    /**
+     * Get html for a form to submit this request to the IDP.
+     *
+     * form_tag_attrs: An array of attributes to be added to the form
+     * tag. 'accept-charset' and 'enctype' have defaults that can be
+     * overridden. If a value is supplied for 'action' or 'method', it
+     * will be replaced.
+     */
+    function formMarkup($realm, $return_to=null, $immediate=false,
+                        $form_tag_attrs=null)
+    {
+        $message = $this->getMessage($realm, $return_to, $immediate);
+
+        if (Auth_OpenID::isFailure($message)) {
+            return $message;
+        }
+
+        return $message->toFormMarkup($this->endpoint->server_url,
+                                      $form_tag_attrs);
+    }
+
+    /**
+     * Get a complete html document that will autosubmit the request
+     * to the IDP.
+     *
+     * Wraps formMarkup.  See the documentation for that function.
+     */
+    function htmlMarkup($realm, $return_to=null, $immediate=false,
+                        $form_tag_attrs=null)
+    {
+        $form = $this->formMarkup($realm, $return_to, $immediate, 
+                                  $form_tag_attrs);
+
+        if (Auth_OpenID::isFailure($form)) {
+            return $form;
+        }
+        return Auth_OpenID::autoSubmitHTML($form);
+    }
+
+    function shouldSendRedirect()
+    {
+        return $this->endpoint->compatibilityMode();
+    }
+}
+
+/**
+ * The base class for responses from the Auth_OpenID_Consumer.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_ConsumerResponse {
+    var $status = null;
+
+    function setEndpoint($endpoint)
+    {
+        $this->endpoint = $endpoint;
+        if ($endpoint === null) {
+            $this->identity_url = null;
+        } else {
+            $this->identity_url = $endpoint->claimed_id;
+        }
+    }
+
+    /**
+     * Return the display identifier for this response.
+     *
+     * The display identifier is related to the Claimed Identifier, but the
+     * two are not always identical.  The display identifier is something the
+     * user should recognize as what they entered, whereas the response's
+     * claimed identifier (in the identity_url attribute) may have extra
+     * information for better persistence.
+     *
+     * URLs will be stripped of their fragments for display.  XRIs will
+     * display the human-readable identifier (i-name) instead of the
+     * persistent identifier (i-number).
+     *
+     * Use the display identifier in your user interface.  Use
+     * identity_url for querying your database or authorization server.
+     *
+     */
+    function getDisplayIdentifier()
+    {
+        if ($this->endpoint !== null) {
+            return $this->endpoint->getDisplayIdentifier();
+        }
+        return null;
+    }
+}
+
+/**
+ * A response with a status of Auth_OpenID_SUCCESS. Indicates that
+ * this request is a successful acknowledgement from the OpenID server
+ * that the supplied URL is, indeed controlled by the requesting
+ * agent.  This has three relevant attributes:
+ *
+ * claimed_id - The identity URL that has been authenticated
+ *
+ * signed_args - The arguments in the server's response that were
+ * signed and verified.
+ *
+ * status - Auth_OpenID_SUCCESS.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SuccessResponse extends Auth_OpenID_ConsumerResponse {
+    var $status = Auth_OpenID_SUCCESS;
+
+    /**
+     * @access private
+     */
+    function Auth_OpenID_SuccessResponse($endpoint, $message, $signed_args=null)
+    {
+        $this->endpoint = $endpoint;
+        $this->identity_url = $endpoint->claimed_id;
+        $this->signed_args = $signed_args;
+        $this->message = $message;
+
+        if ($this->signed_args === null) {
+            $this->signed_args = array();
+        }
+    }
+
+    /**
+     * Extract signed extension data from the server's response.
+     *
+     * @param string $prefix The extension namespace from which to
+     * extract the extension data.
+     */
+    function extensionResponse($namespace_uri, $require_signed)
+    {
+        if ($require_signed) {
+            return $this->getSignedNS($namespace_uri);
+        } else {
+            return $this->message->getArgs($namespace_uri);
+        }
+    }
+
+    function isOpenID1()
+    {
+        return $this->message->isOpenID1();
+    }
+
+    function isSigned($ns_uri, $ns_key)
+    {
+        // Return whether a particular key is signed, regardless of
+        // its namespace alias
+        return in_array($this->message->getKey($ns_uri, $ns_key),
+                        $this->signed_args);
+    }
+
+    function getSigned($ns_uri, $ns_key, $default = null)
+    {
+        // Return the specified signed field if available, otherwise
+        // return default
+        if ($this->isSigned($ns_uri, $ns_key)) {
+            return $this->message->getArg($ns_uri, $ns_key, $default);
+        } else {
+            return $default;
+        }
+    }
+
+    function getSignedNS($ns_uri)
+    {
+        $args = array();
+
+        $msg_args = $this->message->getArgs($ns_uri);
+        if (Auth_OpenID::isFailure($msg_args)) {
+            return null;
+        }
+
+        foreach ($msg_args as $key => $value) {
+            if (!$this->isSigned($ns_uri, $key)) {
+                return null;
+            }
+        }
+
+        return $msg_args;
+    }
+
+    /**
+     * Get the openid.return_to argument from this response.
+     *
+     * This is useful for verifying that this request was initiated by
+     * this consumer.
+     *
+     * @return string $return_to The return_to URL supplied to the
+     * server on the initial request, or null if the response did not
+     * contain an 'openid.return_to' argument.
+    */
+    function getReturnTo()
+    {
+        return $this->getSigned(Auth_OpenID_OPENID_NS, 'return_to');
+    }
+}
+
+/**
+ * A response with a status of Auth_OpenID_FAILURE. Indicates that the
+ * OpenID protocol has failed. This could be locally or remotely
+ * triggered.  This has three relevant attributes:
+ *
+ * claimed_id - The identity URL for which authentication was
+ * attempted, if it can be determined.  Otherwise, null.
+ *
+ * message - A message indicating why the request failed, if one is
+ * supplied.  Otherwise, null.
+ *
+ * status - Auth_OpenID_FAILURE.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_FailureResponse extends Auth_OpenID_ConsumerResponse {
+    var $status = Auth_OpenID_FAILURE;
+
+    function Auth_OpenID_FailureResponse($endpoint, $message = null,
+                                         $contact = null, $reference = null)
+    {
+        $this->setEndpoint($endpoint);
+        $this->message = $message;
+        $this->contact = $contact;
+        $this->reference = $reference;
+    }
+}
+
+/**
+ * A specific, internal failure used to detect type URI mismatch.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_TypeURIMismatch extends Auth_OpenID_FailureResponse {
+}
+
+/**
+ * Exception that is raised when the server returns a 400 response
+ * code to a direct request.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_ServerErrorContainer {
+    function Auth_OpenID_ServerErrorContainer($error_text,
+                                              $error_code,
+                                              $message)
+    {
+        $this->error_text = $error_text;
+        $this->error_code = $error_code;
+        $this->message = $message;
+    }
+
+    /**
+     * @access private
+     */
+    function fromMessage($message)
+    {
+        $error_text = $message->getArg(
+           Auth_OpenID_OPENID_NS, 'error', '<no error message supplied>');
+        $error_code = $message->getArg(Auth_OpenID_OPENID_NS, 'error_code');
+        return new Auth_OpenID_ServerErrorContainer($error_text,
+                                                    $error_code,
+                                                    $message);
+    }
+}
+
+/**
+ * A response with a status of Auth_OpenID_CANCEL. Indicates that the
+ * user cancelled the OpenID authentication request.  This has two
+ * relevant attributes:
+ *
+ * claimed_id - The identity URL for which authentication was
+ * attempted, if it can be determined.  Otherwise, null.
+ *
+ * status - Auth_OpenID_SUCCESS.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_CancelResponse extends Auth_OpenID_ConsumerResponse {
+    var $status = Auth_OpenID_CANCEL;
+
+    function Auth_OpenID_CancelResponse($endpoint)
+    {
+        $this->setEndpoint($endpoint);
+    }
+}
+
+/**
+ * A response with a status of Auth_OpenID_SETUP_NEEDED. Indicates
+ * that the request was in immediate mode, and the server is unable to
+ * authenticate the user without further interaction.
+ *
+ * claimed_id - The identity URL for which authentication was
+ * attempted.
+ *
+ * setup_url - A URL that can be used to send the user to the server
+ * to set up for authentication. The user should be redirected in to
+ * the setup_url, either in the current window or in a new browser
+ * window.  Null in OpenID 2.
+ *
+ * status - Auth_OpenID_SETUP_NEEDED.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SetupNeededResponse extends Auth_OpenID_ConsumerResponse {
+    var $status = Auth_OpenID_SETUP_NEEDED;
+
+    function Auth_OpenID_SetupNeededResponse($endpoint,
+                                             $setup_url = null)
+    {
+        $this->setEndpoint($endpoint);
+        $this->setup_url = $setup_url;
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/CryptUtil.php b/inc/lib/Auth/OpenID/CryptUtil.php
new file mode 100644 (file)
index 0000000..aacc3cd
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+
+/**
+ * CryptUtil: A suite of wrapper utility functions for the OpenID
+ * library.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @access private
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+if (!defined('Auth_OpenID_RAND_SOURCE')) {
+    /**
+     * The filename for a source of random bytes. Define this yourself
+     * if you have a different source of randomness.
+     */
+    define('Auth_OpenID_RAND_SOURCE', '/dev/urandom');
+}
+
+class Auth_OpenID_CryptUtil {
+    /**
+     * Get the specified number of random bytes.
+     *
+     * Attempts to use a cryptographically secure (not predictable)
+     * source of randomness if available. If there is no high-entropy
+     * randomness source available, it will fail. As a last resort,
+     * for non-critical systems, define
+     * <code>Auth_OpenID_RAND_SOURCE</code> as <code>null</code>, and
+     * the code will fall back on a pseudo-random number generator.
+     *
+     * @param int $num_bytes The length of the return value
+     * @return string $bytes random bytes
+     */
+    function getBytes($num_bytes)
+    {
+        static $f = null;
+        $bytes = '';
+        if ($f === null) {
+            if (Auth_OpenID_RAND_SOURCE === null) {
+                $f = false;
+            } else {
+                $f = @fopen(Auth_OpenID_RAND_SOURCE, "r");
+                if ($f === false) {
+                    $msg = 'Define Auth_OpenID_RAND_SOURCE as null to ' .
+                        ' continue with an insecure random number generator.';
+                    trigger_error($msg, E_USER_ERROR);
+                }
+            }
+        }
+        if ($f === false) {
+            // pseudorandom used
+            $bytes = '';
+            for ($i = 0; $i < $num_bytes; $i += 4) {
+                $bytes .= pack('L', mt_rand());
+            }
+            $bytes = substr($bytes, 0, $num_bytes);
+        } else {
+            $bytes = fread($f, $num_bytes);
+        }
+        return $bytes;
+    }
+
+    /**
+     * Produce a string of length random bytes, chosen from chrs.  If
+     * $chrs is null, the resulting string may contain any characters.
+     *
+     * @param integer $length The length of the resulting
+     * randomly-generated string
+     * @param string $chrs A string of characters from which to choose
+     * to build the new string
+     * @return string $result A string of randomly-chosen characters
+     * from $chrs
+     */
+    function randomString($length, $population = null)
+    {
+        if ($population === null) {
+            return Auth_OpenID_CryptUtil::getBytes($length);
+        }
+
+        $popsize = strlen($population);
+
+        if ($popsize > 256) {
+            $msg = 'More than 256 characters supplied to ' . __FUNCTION__;
+            trigger_error($msg, E_USER_ERROR);
+        }
+
+        $duplicate = 256 % $popsize;
+
+        $str = "";
+        for ($i = 0; $i < $length; $i++) {
+            do {
+                $n = ord(Auth_OpenID_CryptUtil::getBytes(1));
+            } while ($n < $duplicate);
+
+            $n %= $popsize;
+            $str .= $population[$n];
+        }
+
+        return $str;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/DatabaseConnection.php b/inc/lib/Auth/OpenID/DatabaseConnection.php
new file mode 100644 (file)
index 0000000..9db6e0e
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+
+/**
+ * The Auth_OpenID_DatabaseConnection class, which is used to emulate
+ * a PEAR database connection.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * An empty base class intended to emulate PEAR connection
+ * functionality in applications that supply their own database
+ * abstraction mechanisms.  See {@link Auth_OpenID_SQLStore} for more
+ * information.  You should subclass this class if you need to create
+ * an SQL store that needs to access its database using an
+ * application's database abstraction layer instead of a PEAR database
+ * connection.  Any subclass of Auth_OpenID_DatabaseConnection MUST
+ * adhere to the interface specified here.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_DatabaseConnection {
+    /**
+     * Sets auto-commit mode on this database connection.
+     *
+     * @param bool $mode True if auto-commit is to be used; false if
+     * not.
+     */
+    function autoCommit($mode)
+    {
+    }
+
+    /**
+     * Run an SQL query with the specified parameters, if any.
+     *
+     * @param string $sql An SQL string with placeholders.  The
+     * placeholders are assumed to be specific to the database engine
+     * for this connection.
+     *
+     * @param array $params An array of parameters to insert into the
+     * SQL string using this connection's escaping mechanism.
+     *
+     * @return mixed $result The result of calling this connection's
+     * internal query function.  The type of result depends on the
+     * underlying database engine.  This method is usually used when
+     * the result of a query is not important, like a DDL query.
+     */
+    function query($sql, $params = array())
+    {
+    }
+
+    /**
+     * Starts a transaction on this connection, if supported.
+     */
+    function begin()
+    {
+    }
+
+    /**
+     * Commits a transaction on this connection, if supported.
+     */
+    function commit()
+    {
+    }
+
+    /**
+     * Performs a rollback on this connection, if supported.
+     */
+    function rollback()
+    {
+    }
+
+    /**
+     * Run an SQL query and return the first column of the first row
+     * of the result set, if any.
+     *
+     * @param string $sql An SQL string with placeholders.  The
+     * placeholders are assumed to be specific to the database engine
+     * for this connection.
+     *
+     * @param array $params An array of parameters to insert into the
+     * SQL string using this connection's escaping mechanism.
+     *
+     * @return mixed $result The value of the first column of the
+     * first row of the result set.  False if no such result was
+     * found.
+     */
+    function getOne($sql, $params = array())
+    {
+    }
+
+    /**
+     * Run an SQL query and return the first row of the result set, if
+     * any.
+     *
+     * @param string $sql An SQL string with placeholders.  The
+     * placeholders are assumed to be specific to the database engine
+     * for this connection.
+     *
+     * @param array $params An array of parameters to insert into the
+     * SQL string using this connection's escaping mechanism.
+     *
+     * @return array $result The first row of the result set, if any,
+     * keyed on column name.  False if no such result was found.
+     */
+    function getRow($sql, $params = array())
+    {
+    }
+
+    /**
+     * Run an SQL query with the specified parameters, if any.
+     *
+     * @param string $sql An SQL string with placeholders.  The
+     * placeholders are assumed to be specific to the database engine
+     * for this connection.
+     *
+     * @param array $params An array of parameters to insert into the
+     * SQL string using this connection's escaping mechanism.
+     *
+     * @return array $result An array of arrays representing the
+     * result of the query; each array is keyed on column name.
+     */
+    function getAll($sql, $params = array())
+    {
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/DiffieHellman.php b/inc/lib/Auth/OpenID/DiffieHellman.php
new file mode 100644 (file)
index 0000000..f4ded7e
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * The OpenID library's Diffie-Hellman implementation.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @access private
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+require_once 'Auth/OpenID.php';
+require_once 'Auth/OpenID/BigMath.php';
+
+function Auth_OpenID_getDefaultMod()
+{
+    return '155172898181473697471232257763715539915724801'.
+        '966915404479707795314057629378541917580651227423'.
+        '698188993727816152646631438561595825688188889951'.
+        '272158842675419950341258706556549803580104870537'.
+        '681476726513255747040765857479291291572334510643'.
+        '245094715007229621094194349783925984760375594985'.
+        '848253359305585439638443';
+}
+
+function Auth_OpenID_getDefaultGen()
+{
+    return '2';
+}
+
+/**
+ * The Diffie-Hellman key exchange class.  This class relies on
+ * {@link Auth_OpenID_MathLibrary} to perform large number operations.
+ *
+ * @access private
+ * @package OpenID
+ */
+class Auth_OpenID_DiffieHellman {
+
+    var $mod;
+    var $gen;
+    var $private;
+    var $lib = null;
+
+    function Auth_OpenID_DiffieHellman($mod = null, $gen = null,
+                                       $private = null, $lib = null)
+    {
+        if ($lib === null) {
+            $this->lib =& Auth_OpenID_getMathLib();
+        } else {
+            $this->lib =& $lib;
+        }
+
+        if ($mod === null) {
+            $this->mod = $this->lib->init(Auth_OpenID_getDefaultMod());
+        } else {
+            $this->mod = $mod;
+        }
+
+        if ($gen === null) {
+            $this->gen = $this->lib->init(Auth_OpenID_getDefaultGen());
+        } else {
+            $this->gen = $gen;
+        }
+
+        if ($private === null) {
+            $r = $this->lib->rand($this->mod);
+            $this->private = $this->lib->add($r, 1);
+        } else {
+            $this->private = $private;
+        }
+
+        $this->public = $this->lib->powmod($this->gen, $this->private,
+                                           $this->mod);
+    }
+
+    function getSharedSecret($composite)
+    {
+        return $this->lib->powmod($composite, $this->private, $this->mod);
+    }
+
+    function getPublicKey()
+    {
+        return $this->public;
+    }
+
+    function usingDefaultValues()
+    {
+        return ($this->mod == Auth_OpenID_getDefaultMod() &&
+                $this->gen == Auth_OpenID_getDefaultGen());
+    }
+
+    function xorSecret($composite, $secret, $hash_func)
+    {
+        $dh_shared = $this->getSharedSecret($composite);
+        $dh_shared_str = $this->lib->longToBinary($dh_shared);
+        $hash_dh_shared = $hash_func($dh_shared_str);
+
+        $xsecret = "";
+        for ($i = 0; $i < Auth_OpenID::bytes($secret); $i++) {
+            $xsecret .= chr(ord($secret[$i]) ^ ord($hash_dh_shared[$i]));
+        }
+
+        return $xsecret;
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/Discover.php b/inc/lib/Auth/OpenID/Discover.php
new file mode 100644 (file)
index 0000000..62aeb1d
--- /dev/null
@@ -0,0 +1,548 @@
+<?php
+
+/**
+ * The OpenID and Yadis discovery implementation for OpenID 1.2.
+ */
+
+require_once "Auth/OpenID.php";
+require_once "Auth/OpenID/Parse.php";
+require_once "Auth/OpenID/Message.php";
+require_once "Auth/Yadis/XRIRes.php";
+require_once "Auth/Yadis/Yadis.php";
+
+// XML namespace value
+define('Auth_OpenID_XMLNS_1_0', 'http://openid.net/xmlns/1.0');
+
+// Yadis service types
+define('Auth_OpenID_TYPE_1_2', 'http://openid.net/signon/1.2');
+define('Auth_OpenID_TYPE_1_1', 'http://openid.net/signon/1.1');
+define('Auth_OpenID_TYPE_1_0', 'http://openid.net/signon/1.0');
+define('Auth_OpenID_TYPE_2_0_IDP', 'http://specs.openid.net/auth/2.0/server');
+define('Auth_OpenID_TYPE_2_0', 'http://specs.openid.net/auth/2.0/signon');
+define('Auth_OpenID_RP_RETURN_TO_URL_TYPE',
+       'http://specs.openid.net/auth/2.0/return_to');
+
+function Auth_OpenID_getOpenIDTypeURIs()
+{
+    return array(Auth_OpenID_TYPE_2_0_IDP,
+                 Auth_OpenID_TYPE_2_0,
+                 Auth_OpenID_TYPE_1_2,
+                 Auth_OpenID_TYPE_1_1,
+                 Auth_OpenID_TYPE_1_0,
+                 Auth_OpenID_RP_RETURN_TO_URL_TYPE);
+}
+
+/**
+ * Object representing an OpenID service endpoint.
+ */
+class Auth_OpenID_ServiceEndpoint {
+    function Auth_OpenID_ServiceEndpoint()
+    {
+        $this->claimed_id = null;
+        $this->server_url = null;
+        $this->type_uris = array();
+        $this->local_id = null;
+        $this->canonicalID = null;
+        $this->used_yadis = false; // whether this came from an XRDS
+        $this->display_identifier = null;
+    }
+
+    function getDisplayIdentifier()
+    {
+        if ($this->display_identifier) {
+            return $this->display_identifier;
+        }
+        if (! $this->claimed_id) {
+          return $this->claimed_id;
+        }
+        $parsed = parse_url($this->claimed_id);
+        $scheme = $parsed['scheme'];
+        $host = $parsed['host'];
+        $path = $parsed['path'];
+        if (array_key_exists('query', $parsed)) {
+            $query = $parsed['query'];
+            $no_frag = "$scheme://$host$path?$query";
+        } else {
+            $no_frag = "$scheme://$host$path";
+        }
+        return $no_frag;
+    }
+
+    function usesExtension($extension_uri)
+    {
+        return in_array($extension_uri, $this->type_uris);
+    }
+
+    function preferredNamespace()
+    {
+        if (in_array(Auth_OpenID_TYPE_2_0_IDP, $this->type_uris) ||
+            in_array(Auth_OpenID_TYPE_2_0, $this->type_uris)) {
+            return Auth_OpenID_OPENID2_NS;
+        } else {
+            return Auth_OpenID_OPENID1_NS;
+        }
+    }
+
+    /*
+     * Query this endpoint to see if it has any of the given type
+     * URIs. This is useful for implementing other endpoint classes
+     * that e.g. need to check for the presence of multiple versions
+     * of a single protocol.
+     *
+     * @param $type_uris The URIs that you wish to check
+     *
+     * @return all types that are in both in type_uris and
+     * $this->type_uris
+     */
+    function matchTypes($type_uris)
+    {
+        $result = array();
+        foreach ($type_uris as $test_uri) {
+            if ($this->supportsType($test_uri)) {
+                $result[] = $test_uri;
+            }
+        }
+
+        return $result;
+    }
+
+    function supportsType($type_uri)
+    {
+        // Does this endpoint support this type?
+        return ((in_array($type_uri, $this->type_uris)) ||
+                (($type_uri == Auth_OpenID_TYPE_2_0) &&
+                 $this->isOPIdentifier()));
+    }
+
+    function compatibilityMode()
+    {
+        return $this->preferredNamespace() != Auth_OpenID_OPENID2_NS;
+    }
+
+    function isOPIdentifier()
+    {
+        return in_array(Auth_OpenID_TYPE_2_0_IDP, $this->type_uris);
+    }
+
+    function fromOPEndpointURL($op_endpoint_url)
+    {
+        // Construct an OP-Identifier OpenIDServiceEndpoint object for
+        // a given OP Endpoint URL
+        $obj = new Auth_OpenID_ServiceEndpoint();
+        $obj->server_url = $op_endpoint_url;
+        $obj->type_uris = array(Auth_OpenID_TYPE_2_0_IDP);
+        return $obj;
+    }
+
+    function parseService($yadis_url, $uri, $type_uris, $service_element)
+    {
+        // Set the state of this object based on the contents of the
+        // service element.  Return true if successful, false if not
+        // (if findOPLocalIdentifier returns false).
+        $this->type_uris = $type_uris;
+        $this->server_url = $uri;
+        $this->used_yadis = true;
+
+        if (!$this->isOPIdentifier()) {
+            $this->claimed_id = $yadis_url;
+            $this->local_id = Auth_OpenID_findOPLocalIdentifier(
+                                                    $service_element,
+                                                    $this->type_uris);
+            if ($this->local_id === false) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    function getLocalID()
+    {
+        // Return the identifier that should be sent as the
+        // openid.identity_url parameter to the server.
+        if ($this->local_id === null && $this->canonicalID === null) {
+            return $this->claimed_id;
+        } else {
+            if ($this->local_id) {
+                return $this->local_id;
+            } else {
+                return $this->canonicalID;
+            }
+        }
+    }
+
+    /*
+     * Parse the given document as XRDS looking for OpenID services.
+     *
+     * @return array of Auth_OpenID_ServiceEndpoint or null if the
+     * document cannot be parsed.
+     */
+    function fromXRDS($uri, $xrds_text)
+    {
+        $xrds =& Auth_Yadis_XRDS::parseXRDS($xrds_text);
+
+        if ($xrds) {
+            $yadis_services =
+              $xrds->services(array('filter_MatchesAnyOpenIDType'));
+            return Auth_OpenID_makeOpenIDEndpoints($uri, $yadis_services);
+        }
+
+        return null;
+    }
+
+    /*
+     * Create endpoints from a DiscoveryResult.
+     *
+     * @param discoveryResult Auth_Yadis_DiscoveryResult
+     * @return array of Auth_OpenID_ServiceEndpoint or null if
+     * endpoints cannot be created.
+     */
+    function fromDiscoveryResult($discoveryResult)
+    {
+        if ($discoveryResult->isXRDS()) {
+            return Auth_OpenID_ServiceEndpoint::fromXRDS(
+                                     $discoveryResult->normalized_uri,
+                                     $discoveryResult->response_text);
+        } else {
+            return Auth_OpenID_ServiceEndpoint::fromHTML(
+                                     $discoveryResult->normalized_uri,
+                                     $discoveryResult->response_text);
+        }
+    }
+
+    function fromHTML($uri, $html)
+    {
+        $discovery_types = array(
+                                 array(Auth_OpenID_TYPE_2_0,
+                                       'openid2.provider', 'openid2.local_id'),
+                                 array(Auth_OpenID_TYPE_1_1,
+                                       'openid.server', 'openid.delegate')
+                                 );
+
+        $services = array();
+
+        foreach ($discovery_types as $triple) {
+            list($type_uri, $server_rel, $delegate_rel) = $triple;
+
+            $urls = Auth_OpenID_legacy_discover($html, $server_rel,
+                                                $delegate_rel);
+
+            if ($urls === false) {
+                continue;
+            }
+
+            list($delegate_url, $server_url) = $urls;
+
+            $service = new Auth_OpenID_ServiceEndpoint();
+            $service->claimed_id = $uri;
+            $service->local_id = $delegate_url;
+            $service->server_url = $server_url;
+            $service->type_uris = array($type_uri);
+
+            $services[] = $service;
+        }
+
+        return $services;
+    }
+
+    function copy()
+    {
+        $x = new Auth_OpenID_ServiceEndpoint();
+
+        $x->claimed_id = $this->claimed_id;
+        $x->server_url = $this->server_url;
+        $x->type_uris = $this->type_uris;
+        $x->local_id = $this->local_id;
+        $x->canonicalID = $this->canonicalID;
+        $x->used_yadis = $this->used_yadis;
+
+        return $x;
+    }
+}
+
+function Auth_OpenID_findOPLocalIdentifier($service, $type_uris)
+{
+    // Extract a openid:Delegate value from a Yadis Service element.
+    // If no delegate is found, returns null.  Returns false on
+    // discovery failure (when multiple delegate/localID tags have
+    // different values).
+
+    $service->parser->registerNamespace('openid',
+                                        Auth_OpenID_XMLNS_1_0);
+
+    $service->parser->registerNamespace('xrd',
+                                        Auth_Yadis_XMLNS_XRD_2_0);
+
+    $parser =& $service->parser;
+
+    $permitted_tags = array();
+
+    if (in_array(Auth_OpenID_TYPE_1_1, $type_uris) ||
+        in_array(Auth_OpenID_TYPE_1_0, $type_uris)) {
+        $permitted_tags[] = 'openid:Delegate';
+    }
+
+    if (in_array(Auth_OpenID_TYPE_2_0, $type_uris)) {
+        $permitted_tags[] = 'xrd:LocalID';
+    }
+
+    $local_id = null;
+
+    foreach ($permitted_tags as $tag_name) {
+        $tags = $service->getElements($tag_name);
+
+        foreach ($tags as $tag) {
+            $content = $parser->content($tag);
+
+            if ($local_id === null) {
+                $local_id = $content;
+            } else if ($local_id != $content) {
+                return false;
+            }
+        }
+    }
+
+    return $local_id;
+}
+
+function filter_MatchesAnyOpenIDType(&$service)
+{
+    $uris = $service->getTypes();
+
+    foreach ($uris as $uri) {
+        if (in_array($uri, Auth_OpenID_getOpenIDTypeURIs())) {
+            return true;
+        }
+    }
+
+    return false;
+}
+
+function Auth_OpenID_bestMatchingService($service, $preferred_types)
+{
+    // Return the index of the first matching type, or something
+    // higher if no type matches.
+    //
+    // This provides an ordering in which service elements that
+    // contain a type that comes earlier in the preferred types list
+    // come before service elements that come later. If a service
+    // element has more than one type, the most preferred one wins.
+
+    foreach ($preferred_types as $index => $typ) {
+        if (in_array($typ, $service->type_uris)) {
+            return $index;
+        }
+    }
+
+    return count($preferred_types);
+}
+
+function Auth_OpenID_arrangeByType($service_list, $preferred_types)
+{
+    // Rearrange service_list in a new list so services are ordered by
+    // types listed in preferred_types.  Return the new list.
+
+    // Build a list with the service elements in tuples whose
+    // comparison will prefer the one with the best matching service
+    $prio_services = array();
+    foreach ($service_list as $index => $service) {
+        $prio_services[] = array(Auth_OpenID_bestMatchingService($service,
+                                                        $preferred_types),
+                                 $index, $service);
+    }
+
+    sort($prio_services);
+
+    // Now that the services are sorted by priority, remove the sort
+    // keys from the list.
+    foreach ($prio_services as $index => $s) {
+        $prio_services[$index] = $prio_services[$index][2];
+    }
+
+    return $prio_services;
+}
+
+// Extract OP Identifier services.  If none found, return the rest,
+// sorted with most preferred first according to
+// OpenIDServiceEndpoint.openid_type_uris.
+//
+// openid_services is a list of OpenIDServiceEndpoint objects.
+//
+// Returns a list of OpenIDServiceEndpoint objects."""
+function Auth_OpenID_getOPOrUserServices($openid_services)
+{
+    $op_services = Auth_OpenID_arrangeByType($openid_services,
+                                     array(Auth_OpenID_TYPE_2_0_IDP));
+
+    $openid_services = Auth_OpenID_arrangeByType($openid_services,
+                                     Auth_OpenID_getOpenIDTypeURIs());
+
+    if ($op_services) {
+        return $op_services;
+    } else {
+        return $openid_services;
+    }
+}
+
+function Auth_OpenID_makeOpenIDEndpoints($uri, $yadis_services)
+{
+    $s = array();
+
+    if (!$yadis_services) {
+        return $s;
+    }
+
+    foreach ($yadis_services as $service) {
+        $type_uris = $service->getTypes();
+        $uris = $service->getURIs();
+
+        // If any Type URIs match and there is an endpoint URI
+        // specified, then this is an OpenID endpoint
+        if ($type_uris &&
+            $uris) {
+            foreach ($uris as $service_uri) {
+                $openid_endpoint = new Auth_OpenID_ServiceEndpoint();
+                if ($openid_endpoint->parseService($uri,
+                                                   $service_uri,
+                                                   $type_uris,
+                                                   $service)) {
+                    $s[] = $openid_endpoint;
+                }
+            }
+        }
+    }
+
+    return $s;
+}
+
+function Auth_OpenID_discoverWithYadis($uri, &$fetcher,
+              $endpoint_filter='Auth_OpenID_getOPOrUserServices',
+              $discover_function=null)
+{
+    // Discover OpenID services for a URI. Tries Yadis and falls back
+    // on old-style <link rel='...'> discovery if Yadis fails.
+
+    // Might raise a yadis.discover.DiscoveryFailure if no document
+    // came back for that URI at all.  I don't think falling back to
+    // OpenID 1.0 discovery on the same URL will help, so don't bother
+    // to catch it.
+    if ($discover_function === null) {
+        $discover_function = array('Auth_Yadis_Yadis', 'discover');
+    }
+
+    $openid_services = array();
+
+    $response = call_user_func_array($discover_function,
+                                     array($uri, &$fetcher));
+
+    $yadis_url = $response->normalized_uri;
+    $yadis_services = array();
+
+    if ($response->isFailure()) {
+        return array($uri, array());
+    }
+
+    $openid_services = Auth_OpenID_ServiceEndpoint::fromXRDS(
+                                         $yadis_url,
+                                         $response->response_text);
+
+    if (!$openid_services) {
+        if ($response->isXRDS()) {
+            return Auth_OpenID_discoverWithoutYadis($uri,
+                                                    $fetcher);
+        }
+
+        // Try to parse the response as HTML to get OpenID 1.0/1.1
+        // <link rel="...">
+        $openid_services = Auth_OpenID_ServiceEndpoint::fromHTML(
+                                        $yadis_url,
+                                        $response->response_text);
+    }
+
+    $openid_services = call_user_func_array($endpoint_filter,
+                                            array(&$openid_services));
+
+    return array($yadis_url, $openid_services);
+}
+
+function Auth_OpenID_discoverURI($uri, &$fetcher)
+{
+    $uri = Auth_OpenID::normalizeUrl($uri);
+    return Auth_OpenID_discoverWithYadis($uri, $fetcher);
+}
+
+function Auth_OpenID_discoverWithoutYadis($uri, &$fetcher)
+{
+    $http_resp = @$fetcher->get($uri);
+
+    if ($http_resp->status != 200 and $http_resp->status != 206) {
+        return array($uri, array());
+    }
+
+    $identity_url = $http_resp->final_url;
+
+    // Try to parse the response as HTML to get OpenID 1.0/1.1 <link
+    // rel="...">
+    $openid_services = Auth_OpenID_ServiceEndpoint::fromHTML(
+                                           $identity_url,
+                                           $http_resp->body);
+
+    return array($identity_url, $openid_services);
+}
+
+function Auth_OpenID_discoverXRI($iname, &$fetcher)
+{
+    $resolver = new Auth_Yadis_ProxyResolver($fetcher);
+    list($canonicalID, $yadis_services) =
+        $resolver->query($iname,
+                         Auth_OpenID_getOpenIDTypeURIs(),
+                         array('filter_MatchesAnyOpenIDType'));
+
+    $openid_services = Auth_OpenID_makeOpenIDEndpoints($iname,
+                                                       $yadis_services);
+
+    $openid_services = Auth_OpenID_getOPOrUserServices($openid_services);
+
+    for ($i = 0; $i < count($openid_services); $i++) {
+        $openid_services[$i]->canonicalID = $canonicalID;
+        $openid_services[$i]->claimed_id = $canonicalID;
+        $openid_services[$i]->display_identifier = $iname;
+    }
+
+    // FIXME: returned xri should probably be in some normal form
+    return array($iname, $openid_services);
+}
+
+function Auth_OpenID_discover($uri, &$fetcher)
+{
+    // If the fetcher (i.e., PHP) doesn't support SSL, we can't do
+    // discovery on an HTTPS URL.
+    if ($fetcher->isHTTPS($uri) && !$fetcher->supportsSSL()) {
+        return array($uri, array());
+    }
+
+    if (Auth_Yadis_identifierScheme($uri) == 'XRI') {
+        $result = Auth_OpenID_discoverXRI($uri, $fetcher);
+    } else {
+        $result = Auth_OpenID_discoverURI($uri, $fetcher);
+    }
+
+    // If the fetcher doesn't support SSL, we can't interact with
+    // HTTPS server URLs; remove those endpoints from the list.
+    if (!$fetcher->supportsSSL()) {
+        $http_endpoints = array();
+        list($new_uri, $endpoints) = $result;
+
+        foreach ($endpoints as $e) {
+            if (!$fetcher->isHTTPS($e->server_url)) {
+                $http_endpoints[] = $e;
+            }
+        }
+
+        $result = array($new_uri, $http_endpoints);
+    }
+
+    return $result;
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/DumbStore.php b/inc/lib/Auth/OpenID/DumbStore.php
new file mode 100644 (file)
index 0000000..22fd2d3
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+/**
+ * This file supplies a dumb store backend for OpenID servers and
+ * consumers.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Import the interface for creating a new store class.
+ */
+require_once 'Auth/OpenID/Interface.php';
+require_once 'Auth/OpenID/HMAC.php';
+
+/**
+ * This is a store for use in the worst case, when you have no way of
+ * saving state on the consumer site. Using this store makes the
+ * consumer vulnerable to replay attacks, as it's unable to use
+ * nonces. Avoid using this store if it is at all possible.
+ *
+ * Most of the methods of this class are implementation details.
+ * Users of this class need to worry only about the constructor.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_DumbStore extends Auth_OpenID_OpenIDStore {
+
+    /**
+     * Creates a new {@link Auth_OpenID_DumbStore} instance. For the security
+     * of the tokens generated by the library, this class attempts to
+     * at least have a secure implementation of getAuthKey.
+     *
+     * When you create an instance of this class, pass in a secret
+     * phrase. The phrase is hashed with sha1 to make it the correct
+     * length and form for an auth key. That allows you to use a long
+     * string as the secret phrase, which means you can make it very
+     * difficult to guess.
+     *
+     * Each {@link Auth_OpenID_DumbStore} instance that is created for use by
+     * your consumer site needs to use the same $secret_phrase.
+     *
+     * @param string secret_phrase The phrase used to create the auth
+     * key returned by getAuthKey
+     */
+    function Auth_OpenID_DumbStore($secret_phrase)
+    {
+        $this->auth_key = Auth_OpenID_SHA1($secret_phrase);
+    }
+
+    /**
+     * This implementation does nothing.
+     */
+    function storeAssociation($server_url, $association)
+    {
+    }
+
+    /**
+     * This implementation always returns null.
+     */
+    function getAssociation($server_url, $handle = null)
+    {
+        return null;
+    }
+
+    /**
+     * This implementation always returns false.
+     */
+    function removeAssociation($server_url, $handle)
+    {
+        return false;
+    }
+
+    /**
+     * In a system truly limited to dumb mode, nonces must all be
+     * accepted. This therefore always returns true, which makes
+     * replay attacks feasible.
+     */
+    function useNonce($server_url, $timestamp, $salt)
+    {
+        return true;
+    }
+
+    /**
+     * This method returns the auth key generated by the constructor.
+     */
+    function getAuthKey()
+    {
+        return $this->auth_key;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/Extension.php b/inc/lib/Auth/OpenID/Extension.php
new file mode 100644 (file)
index 0000000..f362a4b
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+/**
+ * An interface for OpenID extensions.
+ *
+ * @package OpenID
+ */
+
+/**
+ * Require the Message implementation.
+ */
+require_once 'Auth/OpenID/Message.php';
+
+/**
+ * A base class for accessing extension request and response data for
+ * the OpenID 2 protocol.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Extension {
+    /**
+     * ns_uri: The namespace to which to add the arguments for this
+     * extension
+     */
+    var $ns_uri = null;
+    var $ns_alias = null;
+
+    /**
+     * Get the string arguments that should be added to an OpenID
+     * message for this extension.
+     */
+    function getExtensionArgs()
+    {
+        return null;
+    }
+
+    /**
+     * Add the arguments from this extension to the provided message.
+     *
+     * Returns the message with the extension arguments added.
+     */
+    function toMessage(&$message)
+    {
+        $implicit = $message->isOpenID1();
+        $added = $message->namespaces->addAlias($this->ns_uri,
+                                                $this->ns_alias,
+                                                $implicit);
+
+        if ($added === null) {
+            if ($message->namespaces->getAlias($this->ns_uri) !=
+                $this->ns_alias) {
+                return null;
+            }
+        }
+
+        $message->updateArgs($this->ns_uri,
+                             $this->getExtensionArgs());
+        return $message;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/FileStore.php b/inc/lib/Auth/OpenID/FileStore.php
new file mode 100644 (file)
index 0000000..29d8d20
--- /dev/null
@@ -0,0 +1,618 @@
+<?php
+
+/**
+ * This file supplies a Memcached store backend for OpenID servers and
+ * consumers.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Require base class for creating a new interface.
+ */
+require_once 'Auth/OpenID.php';
+require_once 'Auth/OpenID/Interface.php';
+require_once 'Auth/OpenID/HMAC.php';
+require_once 'Auth/OpenID/Nonce.php';
+
+/**
+ * This is a filesystem-based store for OpenID associations and
+ * nonces.  This store should be safe for use in concurrent systems on
+ * both windows and unix (excluding NFS filesystems).  There are a
+ * couple race conditions in the system, but those failure cases have
+ * been set up in such a way that the worst-case behavior is someone
+ * having to try to log in a second time.
+ *
+ * Most of the methods of this class are implementation details.
+ * People wishing to just use this store need only pay attention to
+ * the constructor.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_FileStore extends Auth_OpenID_OpenIDStore {
+
+    /**
+     * Initializes a new {@link Auth_OpenID_FileStore}.  This
+     * initializes the nonce and association directories, which are
+     * subdirectories of the directory passed in.
+     *
+     * @param string $directory This is the directory to put the store
+     * directories in.
+     */
+    function Auth_OpenID_FileStore($directory)
+    {
+        if (!Auth_OpenID::ensureDir($directory)) {
+            trigger_error('Not a directory and failed to create: '
+                          . $directory, E_USER_ERROR);
+        }
+        $directory = realpath($directory);
+
+        $this->directory = $directory;
+        $this->active = true;
+
+        $this->nonce_dir = $directory . DIRECTORY_SEPARATOR . 'nonces';
+
+        $this->association_dir = $directory . DIRECTORY_SEPARATOR .
+            'associations';
+
+        // Temp dir must be on the same filesystem as the assciations
+        // $directory.
+        $this->temp_dir = $directory . DIRECTORY_SEPARATOR . 'temp';
+
+        $this->max_nonce_age = 6 * 60 * 60; // Six hours, in seconds
+
+        if (!$this->_setup()) {
+            trigger_error('Failed to initialize OpenID file store in ' .
+                          $directory, E_USER_ERROR);
+        }
+    }
+
+    function destroy()
+    {
+        Auth_OpenID_FileStore::_rmtree($this->directory);
+        $this->active = false;
+    }
+
+    /**
+     * Make sure that the directories in which we store our data
+     * exist.
+     *
+     * @access private
+     */
+    function _setup()
+    {
+        return (Auth_OpenID::ensureDir($this->nonce_dir) &&
+                Auth_OpenID::ensureDir($this->association_dir) &&
+                Auth_OpenID::ensureDir($this->temp_dir));
+    }
+
+    /**
+     * Create a temporary file on the same filesystem as
+     * $this->association_dir.
+     *
+     * The temporary directory should not be cleaned if there are any
+     * processes using the store. If there is no active process using
+     * the store, it is safe to remove all of the files in the
+     * temporary directory.
+     *
+     * @return array ($fd, $filename)
+     * @access private
+     */
+    function _mktemp()
+    {
+        $name = Auth_OpenID_FileStore::_mkstemp($dir = $this->temp_dir);
+        $file_obj = @fopen($name, 'wb');
+        if ($file_obj !== false) {
+            return array($file_obj, $name);
+        } else {
+            Auth_OpenID_FileStore::_removeIfPresent($name);
+        }
+    }
+
+    function cleanupNonces()
+    {
+        global $Auth_OpenID_SKEW;
+
+        $nonces = Auth_OpenID_FileStore::_listdir($this->nonce_dir);
+        $now = time();
+
+        $removed = 0;
+        // Check all nonces for expiry
+        foreach ($nonces as $nonce_fname) {
+            $base = basename($nonce_fname);
+            $parts = explode('-', $base, 2);
+            $timestamp = $parts[0];
+            $timestamp = intval($timestamp, 16);
+            if (abs($timestamp - $now) > $Auth_OpenID_SKEW) {
+                Auth_OpenID_FileStore::_removeIfPresent($nonce_fname);
+                $removed += 1;
+            }
+        }
+        return $removed;
+    }
+
+    /**
+     * Create a unique filename for a given server url and
+     * handle. This implementation does not assume anything about the
+     * format of the handle. The filename that is returned will
+     * contain the domain name from the server URL for ease of human
+     * inspection of the data directory.
+     *
+     * @return string $filename
+     */
+    function getAssociationFilename($server_url, $handle)
+    {
+        if (!$this->active) {
+            trigger_error("FileStore no longer active", E_USER_ERROR);
+            return null;
+        }
+
+        if (strpos($server_url, '://') === false) {
+            trigger_error(sprintf("Bad server URL: %s", $server_url),
+                          E_USER_WARNING);
+            return null;
+        }
+
+        list($proto, $rest) = explode('://', $server_url, 2);
+        $parts = explode('/', $rest);
+        $domain = Auth_OpenID_FileStore::_filenameEscape($parts[0]);
+        $url_hash = Auth_OpenID_FileStore::_safe64($server_url);
+        if ($handle) {
+            $handle_hash = Auth_OpenID_FileStore::_safe64($handle);
+        } else {
+            $handle_hash = '';
+        }
+
+        $filename = sprintf('%s-%s-%s-%s', $proto, $domain, $url_hash,
+                            $handle_hash);
+
+        return $this->association_dir. DIRECTORY_SEPARATOR . $filename;
+    }
+
+    /**
+     * Store an association in the association directory.
+     */
+    function storeAssociation($server_url, $association)
+    {
+        if (!$this->active) {
+            trigger_error("FileStore no longer active", E_USER_ERROR);
+            return false;
+        }
+
+        $association_s = $association->serialize();
+        $filename = $this->getAssociationFilename($server_url,
+                                                  $association->handle);
+        list($tmp_file, $tmp) = $this->_mktemp();
+
+        if (!$tmp_file) {
+            trigger_error("_mktemp didn't return a valid file descriptor",
+                          E_USER_WARNING);
+            return false;
+        }
+
+        fwrite($tmp_file, $association_s);
+
+        fflush($tmp_file);
+
+        fclose($tmp_file);
+
+        if (@rename($tmp, $filename)) {
+            return true;
+        } else {
+            // In case we are running on Windows, try unlinking the
+            // file in case it exists.
+            @unlink($filename);
+
+            // Now the target should not exist. Try renaming again,
+            // giving up if it fails.
+            if (@rename($tmp, $filename)) {
+                return true;
+            }
+        }
+
+        // If there was an error, don't leave the temporary file
+        // around.
+        Auth_OpenID_FileStore::_removeIfPresent($tmp);
+        return false;
+    }
+
+    /**
+     * Retrieve an association. If no handle is specified, return the
+     * association with the most recent issue time.
+     *
+     * @return mixed $association
+     */
+    function getAssociation($server_url, $handle = null)
+    {
+        if (!$this->active) {
+            trigger_error("FileStore no longer active", E_USER_ERROR);
+            return null;
+        }
+
+        if ($handle === null) {
+            $handle = '';
+        }
+
+        // The filename with the empty handle is a prefix of all other
+        // associations for the given server URL.
+        $filename = $this->getAssociationFilename($server_url, $handle);
+
+        if ($handle) {
+            return $this->_getAssociation($filename);
+        } else {
+            $association_files =
+                Auth_OpenID_FileStore::_listdir($this->association_dir);
+            $matching_files = array();
+
+            // strip off the path to do the comparison
+            $name = basename($filename);
+            foreach ($association_files as $association_file) {
+                $base = basename($association_file);
+                if (strpos($base, $name) === 0) {
+                    $matching_files[] = $association_file;
+                }
+            }
+
+            $matching_associations = array();
+            // read the matching files and sort by time issued
+            foreach ($matching_files as $full_name) {
+                $association = $this->_getAssociation($full_name);
+                if ($association !== null) {
+                    $matching_associations[] = array($association->issued,
+                                                     $association);
+                }
+            }
+
+            $issued = array();
+            $assocs = array();
+            foreach ($matching_associations as $key => $assoc) {
+                $issued[$key] = $assoc[0];
+                $assocs[$key] = $assoc[1];
+            }
+
+            array_multisort($issued, SORT_DESC, $assocs, SORT_DESC,
+                            $matching_associations);
+
+            // return the most recently issued one.
+            if ($matching_associations) {
+                list($issued, $assoc) = $matching_associations[0];
+                return $assoc;
+            } else {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _getAssociation($filename)
+    {
+        if (!$this->active) {
+            trigger_error("FileStore no longer active", E_USER_ERROR);
+            return null;
+        }
+
+        $assoc_file = @fopen($filename, 'rb');
+
+        if ($assoc_file === false) {
+            return null;
+        }
+
+        $assoc_s = fread($assoc_file, filesize($filename));
+        fclose($assoc_file);
+
+        if (!$assoc_s) {
+            return null;
+        }
+
+        $association =
+            Auth_OpenID_Association::deserialize('Auth_OpenID_Association',
+                                                $assoc_s);
+
+        if (!$association) {
+            Auth_OpenID_FileStore::_removeIfPresent($filename);
+            return null;
+        }
+
+        if ($association->getExpiresIn() == 0) {
+            Auth_OpenID_FileStore::_removeIfPresent($filename);
+            return null;
+        } else {
+            return $association;
+        }
+    }
+
+    /**
+     * Remove an association if it exists. Do nothing if it does not.
+     *
+     * @return bool $success
+     */
+    function removeAssociation($server_url, $handle)
+    {
+        if (!$this->active) {
+            trigger_error("FileStore no longer active", E_USER_ERROR);
+            return null;
+        }
+
+        $assoc = $this->getAssociation($server_url, $handle);
+        if ($assoc === null) {
+            return false;
+        } else {
+            $filename = $this->getAssociationFilename($server_url, $handle);
+            return Auth_OpenID_FileStore::_removeIfPresent($filename);
+        }
+    }
+
+    /**
+     * Return whether this nonce is present. As a side effect, mark it
+     * as no longer present.
+     *
+     * @return bool $present
+     */
+    function useNonce($server_url, $timestamp, $salt)
+    {
+        global $Auth_OpenID_SKEW;
+
+        if (!$this->active) {
+            trigger_error("FileStore no longer active", E_USER_ERROR);
+            return null;
+        }
+
+        if ( abs($timestamp - time()) > $Auth_OpenID_SKEW ) {
+            return False;
+        }
+
+        if ($server_url) {
+            list($proto, $rest) = explode('://', $server_url, 2);
+        } else {
+            $proto = '';
+            $rest = '';
+        }
+
+        $parts = explode('/', $rest, 2);
+        $domain = $this->_filenameEscape($parts[0]);
+        $url_hash = $this->_safe64($server_url);
+        $salt_hash = $this->_safe64($salt);
+
+        $filename = sprintf('%08x-%s-%s-%s-%s', $timestamp, $proto,
+                            $domain, $url_hash, $salt_hash);
+        $filename = $this->nonce_dir . DIRECTORY_SEPARATOR . $filename;
+
+        $result = @fopen($filename, 'x');
+
+        if ($result === false) {
+            return false;
+        } else {
+            fclose($result);
+            return true;
+        }
+    }
+
+    /**
+     * Remove expired entries from the database. This is potentially
+     * expensive, so only run when it is acceptable to take time.
+     *
+     * @access private
+     */
+    function _allAssocs()
+    {
+        $all_associations = array();
+
+        $association_filenames =
+            Auth_OpenID_FileStore::_listdir($this->association_dir);
+
+        foreach ($association_filenames as $association_filename) {
+            $association_file = fopen($association_filename, 'rb');
+
+            if ($association_file !== false) {
+                $assoc_s = fread($association_file,
+                                 filesize($association_filename));
+                fclose($association_file);
+
+                // Remove expired or corrupted associations
+                $association =
+                  Auth_OpenID_Association::deserialize(
+                         'Auth_OpenID_Association', $assoc_s);
+
+                if ($association === null) {
+                    Auth_OpenID_FileStore::_removeIfPresent(
+                                                 $association_filename);
+                } else {
+                    if ($association->getExpiresIn() == 0) {
+                        $all_associations[] = array($association_filename,
+                                                    $association);
+                    }
+                }
+            }
+        }
+
+        return $all_associations;
+    }
+
+    function clean()
+    {
+        if (!$this->active) {
+            trigger_error("FileStore no longer active", E_USER_ERROR);
+            return null;
+        }
+
+        $nonces = Auth_OpenID_FileStore::_listdir($this->nonce_dir);
+        $now = time();
+
+        // Check all nonces for expiry
+        foreach ($nonces as $nonce) {
+            if (!Auth_OpenID_checkTimestamp($nonce, $now)) {
+                $filename = $this->nonce_dir . DIRECTORY_SEPARATOR . $nonce;
+                Auth_OpenID_FileStore::_removeIfPresent($filename);
+            }
+        }
+
+        foreach ($this->_allAssocs() as $pair) {
+            list($assoc_filename, $assoc) = $pair;
+            if ($assoc->getExpiresIn() == 0) {
+                Auth_OpenID_FileStore::_removeIfPresent($assoc_filename);
+            }
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _rmtree($dir)
+    {
+        if ($dir[strlen($dir) - 1] != DIRECTORY_SEPARATOR) {
+            $dir .= DIRECTORY_SEPARATOR;
+        }
+
+        if ($handle = opendir($dir)) {
+            while ($item = readdir($handle)) {
+                if (!in_array($item, array('.', '..'))) {
+                    if (is_dir($dir . $item)) {
+
+                        if (!Auth_OpenID_FileStore::_rmtree($dir . $item)) {
+                            return false;
+                        }
+                    } else if (is_file($dir . $item)) {
+                        if (!unlink($dir . $item)) {
+                            return false;
+                        }
+                    }
+                }
+            }
+
+            closedir($handle);
+
+            if (!@rmdir($dir)) {
+                return false;
+            }
+
+            return true;
+        } else {
+            // Couldn't open directory.
+            return false;
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _mkstemp($dir)
+    {
+        foreach (range(0, 4) as $i) {
+            $name = tempnam($dir, "php_openid_filestore_");
+
+            if ($name !== false) {
+                return $name;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @access private
+     */
+    function _mkdtemp($dir)
+    {
+        foreach (range(0, 4) as $i) {
+            $name = $dir . strval(DIRECTORY_SEPARATOR) . strval(getmypid()) .
+                "-" . strval(rand(1, time()));
+            if (!mkdir($name, 0700)) {
+                return false;
+            } else {
+                return $name;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @access private
+     */
+    function _listdir($dir)
+    {
+        $handle = opendir($dir);
+        $files = array();
+        while (false !== ($filename = readdir($handle))) {
+            if (!in_array($filename, array('.', '..'))) {
+                $files[] = $dir . DIRECTORY_SEPARATOR . $filename;
+            }
+        }
+        return $files;
+    }
+
+    /**
+     * @access private
+     */
+    function _isFilenameSafe($char)
+    {
+        $_Auth_OpenID_filename_allowed = Auth_OpenID_letters .
+            Auth_OpenID_digits . ".";
+        return (strpos($_Auth_OpenID_filename_allowed, $char) !== false);
+    }
+
+    /**
+     * @access private
+     */
+    function _safe64($str)
+    {
+        $h64 = base64_encode(Auth_OpenID_SHA1($str));
+        $h64 = str_replace('+', '_', $h64);
+        $h64 = str_replace('/', '.', $h64);
+        $h64 = str_replace('=', '', $h64);
+        return $h64;
+    }
+
+    /**
+     * @access private
+     */
+    function _filenameEscape($str)
+    {
+        $filename = "";
+        $b = Auth_OpenID::toBytes($str);
+
+        for ($i = 0; $i < count($b); $i++) {
+            $c = $b[$i];
+            if (Auth_OpenID_FileStore::_isFilenameSafe($c)) {
+                $filename .= $c;
+            } else {
+                $filename .= sprintf("_%02X", ord($c));
+            }
+        }
+        return $filename;
+    }
+
+    /**
+     * Attempt to remove a file, returning whether the file existed at
+     * the time of the call.
+     *
+     * @access private
+     * @return bool $result True if the file was present, false if not.
+     */
+    function _removeIfPresent($filename)
+    {
+        return @unlink($filename);
+    }
+
+    function cleanupAssociations()
+    {
+        $removed = 0;
+        foreach ($this->_allAssocs() as $pair) {
+            list($assoc_filename, $assoc) = $pair;
+            if ($assoc->getExpiresIn() == 0) {
+                $this->_removeIfPresent($assoc_filename);
+                $removed += 1;
+            }
+        }
+        return $removed;
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/HMAC.php b/inc/lib/Auth/OpenID/HMAC.php
new file mode 100644 (file)
index 0000000..ec42db8
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+/**
+ * This is the HMACSHA1 implementation for the OpenID library.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @access private
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+require_once 'Auth/OpenID.php';
+
+/**
+ * SHA1_BLOCKSIZE is this module's SHA1 blocksize used by the fallback
+ * implementation.
+ */
+define('Auth_OpenID_SHA1_BLOCKSIZE', 64);
+
+function Auth_OpenID_SHA1($text)
+{
+    if (function_exists('hash') &&
+        function_exists('hash_algos') &&
+        (in_array('sha1', hash_algos()))) {
+        // PHP 5 case (sometimes): 'hash' available and 'sha1' algo
+        // supported.
+        return hash('sha1', $text, true);
+    } else if (function_exists('sha1')) {
+        // PHP 4 case: 'sha1' available.
+        $hex = sha1($text);
+        $raw = '';
+        for ($i = 0; $i < 40; $i += 2) {
+            $hexcode = substr($hex, $i, 2);
+            $charcode = (int)base_convert($hexcode, 16, 10);
+            $raw .= chr($charcode);
+        }
+        return $raw;
+    } else {
+        // Explode.
+        trigger_error('No SHA1 function found', E_USER_ERROR);
+    }
+}
+
+/**
+ * Compute an HMAC/SHA1 hash.
+ *
+ * @access private
+ * @param string $key The HMAC key
+ * @param string $text The message text to hash
+ * @return string $mac The MAC
+ */
+function Auth_OpenID_HMACSHA1($key, $text)
+{
+    if (Auth_OpenID::bytes($key) > Auth_OpenID_SHA1_BLOCKSIZE) {
+        $key = Auth_OpenID_SHA1($key, true);
+    }
+
+    $key = str_pad($key, Auth_OpenID_SHA1_BLOCKSIZE, chr(0x00));
+    $ipad = str_repeat(chr(0x36), Auth_OpenID_SHA1_BLOCKSIZE);
+    $opad = str_repeat(chr(0x5c), Auth_OpenID_SHA1_BLOCKSIZE);
+    $hash1 = Auth_OpenID_SHA1(($key ^ $ipad) . $text, true);
+    $hmac = Auth_OpenID_SHA1(($key ^ $opad) . $hash1, true);
+    return $hmac;
+}
+
+if (function_exists('hash') &&
+    function_exists('hash_algos') &&
+    (in_array('sha256', hash_algos()))) {
+    function Auth_OpenID_SHA256($text)
+    {
+        // PHP 5 case: 'hash' available and 'sha256' algo supported.
+        return hash('sha256', $text, true);
+    }
+    define('Auth_OpenID_SHA256_SUPPORTED', true);
+} else {
+    define('Auth_OpenID_SHA256_SUPPORTED', false);
+}
+
+if (function_exists('hash_hmac') &&
+    function_exists('hash_algos') &&
+    (in_array('sha256', hash_algos()))) {
+
+    function Auth_OpenID_HMACSHA256($key, $text)
+    {
+        // Return raw MAC (not hex string).
+        return hash_hmac('sha256', $text, $key, true);
+    }
+
+    define('Auth_OpenID_HMACSHA256_SUPPORTED', true);
+} else {
+    define('Auth_OpenID_HMACSHA256_SUPPORTED', false);
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/Interface.php b/inc/lib/Auth/OpenID/Interface.php
new file mode 100644 (file)
index 0000000..f4c6062
--- /dev/null
@@ -0,0 +1,197 @@
+<?php
+
+/**
+ * This file specifies the interface for PHP OpenID store implementations.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * This is the interface for the store objects the OpenID library
+ * uses. It is a single class that provides all of the persistence
+ * mechanisms that the OpenID library needs, for both servers and
+ * consumers.  If you want to create an SQL-driven store, please see
+ * then {@link Auth_OpenID_SQLStore} class.
+ *
+ * Change: Version 2.0 removed the storeNonce, getAuthKey, and isDumb
+ * methods, and changed the behavior of the useNonce method to support
+ * one-way nonces.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ */
+class Auth_OpenID_OpenIDStore {
+    /**
+     * This method puts an Association object into storage,
+     * retrievable by server URL and handle.
+     *
+     * @param string $server_url The URL of the identity server that
+     * this association is with. Because of the way the server portion
+     * of the library uses this interface, don't assume there are any
+     * limitations on the character set of the input string. In
+     * particular, expect to see unescaped non-url-safe characters in
+     * the server_url field.
+     *
+     * @param Association $association The Association to store.
+     */
+    function storeAssociation($server_url, $association)
+    {
+        trigger_error("Auth_OpenID_OpenIDStore::storeAssociation ".
+                      "not implemented", E_USER_ERROR);
+    }
+
+    /*
+     * Remove expired nonces from the store.
+     *
+     * Discards any nonce from storage that is old enough that its
+     * timestamp would not pass useNonce().
+     *
+     * This method is not called in the normal operation of the
+     * library.  It provides a way for store admins to keep their
+     * storage from filling up with expired data.
+     *
+     * @return the number of nonces expired
+     */
+    function cleanupNonces()
+    {
+        trigger_error("Auth_OpenID_OpenIDStore::cleanupNonces ".
+                      "not implemented", E_USER_ERROR);
+    }
+
+    /*
+     * Remove expired associations from the store.
+     *
+     * This method is not called in the normal operation of the
+     * library.  It provides a way for store admins to keep their
+     * storage from filling up with expired data.
+     *
+     * @return the number of associations expired.
+     */
+    function cleanupAssociations()
+    {
+        trigger_error("Auth_OpenID_OpenIDStore::cleanupAssociations ".
+                      "not implemented", E_USER_ERROR);
+    }
+
+    /*
+     * Shortcut for cleanupNonces(), cleanupAssociations().
+     *
+     * This method is not called in the normal operation of the
+     * library.  It provides a way for store admins to keep their
+     * storage from filling up with expired data.
+     */
+    function cleanup()
+    {
+        return array($this->cleanupNonces(),
+                     $this->cleanupAssociations());
+    }
+
+    /**
+     * Report whether this storage supports cleanup
+     */
+    function supportsCleanup()
+    {
+        return true;
+    }
+
+    /**
+     * This method returns an Association object from storage that
+     * matches the server URL and, if specified, handle. It returns
+     * null if no such association is found or if the matching
+     * association is expired.
+     *
+     * If no handle is specified, the store may return any association
+     * which matches the server URL. If multiple associations are
+     * valid, the recommended return value for this method is the one
+     * most recently issued.
+     *
+     * This method is allowed (and encouraged) to garbage collect
+     * expired associations when found. This method must not return
+     * expired associations.
+     *
+     * @param string $server_url The URL of the identity server to get
+     * the association for. Because of the way the server portion of
+     * the library uses this interface, don't assume there are any
+     * limitations on the character set of the input string.  In
+     * particular, expect to see unescaped non-url-safe characters in
+     * the server_url field.
+     *
+     * @param mixed $handle This optional parameter is the handle of
+     * the specific association to get. If no specific handle is
+     * provided, any valid association matching the server URL is
+     * returned.
+     *
+     * @return Association The Association for the given identity
+     * server.
+     */
+    function getAssociation($server_url, $handle = null)
+    {
+        trigger_error("Auth_OpenID_OpenIDStore::getAssociation ".
+                      "not implemented", E_USER_ERROR);
+    }
+
+    /**
+     * This method removes the matching association if it's found, and
+     * returns whether the association was removed or not.
+     *
+     * @param string $server_url The URL of the identity server the
+     * association to remove belongs to. Because of the way the server
+     * portion of the library uses this interface, don't assume there
+     * are any limitations on the character set of the input
+     * string. In particular, expect to see unescaped non-url-safe
+     * characters in the server_url field.
+     *
+     * @param string $handle This is the handle of the association to
+     * remove. If there isn't an association found that matches both
+     * the given URL and handle, then there was no matching handle
+     * found.
+     *
+     * @return mixed Returns whether or not the given association existed.
+     */
+    function removeAssociation($server_url, $handle)
+    {
+        trigger_error("Auth_OpenID_OpenIDStore::removeAssociation ".
+                      "not implemented", E_USER_ERROR);
+    }
+
+    /**
+     * Called when using a nonce.
+     *
+     * This method should return C{True} if the nonce has not been
+     * used before, and store it for a while to make sure nobody
+     * tries to use the same value again.  If the nonce has already
+     * been used, return C{False}.
+     *
+     * Change: In earlier versions, round-trip nonces were used and a
+     * nonce was only valid if it had been previously stored with
+     * storeNonce.  Version 2.0 uses one-way nonces, requiring a
+     * different implementation here that does not depend on a
+     * storeNonce call.  (storeNonce is no longer part of the
+     * interface.
+     *
+     * @param string $nonce The nonce to use.
+     *
+     * @return bool Whether or not the nonce was valid.
+     */
+    function useNonce($server_url, $timestamp, $salt)
+    {
+        trigger_error("Auth_OpenID_OpenIDStore::useNonce ".
+                      "not implemented", E_USER_ERROR);
+    }
+
+    /**
+     * Removes all entries from the store; implementation is optional.
+     */
+    function reset()
+    {
+    }
+
+}
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/KVForm.php b/inc/lib/Auth/OpenID/KVForm.php
new file mode 100644 (file)
index 0000000..fb342a0
--- /dev/null
@@ -0,0 +1,112 @@
+<?php
+
+/**
+ * OpenID protocol key-value/comma-newline format parsing and
+ * serialization
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @access private
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Container for key-value/comma-newline OpenID format and parsing
+ */
+class Auth_OpenID_KVForm {
+    /**
+     * Convert an OpenID colon/newline separated string into an
+     * associative array
+     *
+     * @static
+     * @access private
+     */
+    function toArray($kvs, $strict=false)
+    {
+        $lines = explode("\n", $kvs);
+
+        $last = array_pop($lines);
+        if ($last !== '') {
+            array_push($lines, $last);
+            if ($strict) {
+                return false;
+            }
+        }
+
+        $values = array();
+
+        for ($lineno = 0; $lineno < count($lines); $lineno++) {
+            $line = $lines[$lineno];
+            $kv = explode(':', $line, 2);
+            if (count($kv) != 2) {
+                if ($strict) {
+                    return false;
+                }
+                continue;
+            }
+
+            $key = $kv[0];
+            $tkey = trim($key);
+            if ($tkey != $key) {
+                if ($strict) {
+                    return false;
+                }
+            }
+
+            $value = $kv[1];
+            $tval = trim($value);
+            if ($tval != $value) {
+                if ($strict) {
+                    return false;
+                }
+            }
+
+            $values[$tkey] = $tval;
+        }
+
+        return $values;
+    }
+
+    /**
+     * Convert an array into an OpenID colon/newline separated string
+     *
+     * @static
+     * @access private
+     */
+    function fromArray($values)
+    {
+        if ($values === null) {
+            return null;
+        }
+
+        ksort($values);
+
+        $serialized = '';
+        foreach ($values as $key => $value) {
+            if (is_array($value)) {
+                list($key, $value) = array($value[0], $value[1]);
+            }
+
+            if (strpos($key, ':') !== false) {
+                return null;
+            }
+
+            if (strpos($key, "\n") !== false) {
+                return null;
+            }
+
+            if (strpos($value, "\n") !== false) {
+                return null;
+            }
+            $serialized .= "$key:$value\n";
+        }
+        return $serialized;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/MemcachedStore.php b/inc/lib/Auth/OpenID/MemcachedStore.php
new file mode 100644 (file)
index 0000000..d357c6b
--- /dev/null
@@ -0,0 +1,208 @@
+<?php
+
+/**
+ * This file supplies a memcached store backend for OpenID servers and
+ * consumers.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author Artemy Tregubenko <me@arty.name>
+ * @copyright 2008 JanRain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ * Contributed by Open Web Technologies <http://openwebtech.ru/>
+ */
+
+/**
+ * Import the interface for creating a new store class.
+ */
+require_once 'Auth/OpenID/Interface.php';
+
+/**
+ * This is a memcached-based store for OpenID associations and
+ * nonces. 
+ * 
+ * As memcache has limit of 250 chars for key length, 
+ * server_url, handle and salt are hashed with sha1(). 
+ *
+ * Most of the methods of this class are implementation details.
+ * People wishing to just use this store need only pay attention to
+ * the constructor.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_MemcachedStore extends Auth_OpenID_OpenIDStore {
+
+    /**
+     * Initializes a new {@link Auth_OpenID_MemcachedStore} instance.
+     * Just saves memcached object as property.
+     *
+     * @param resource connection Memcache connection resourse
+     */
+    function Auth_OpenID_MemcachedStore($connection, $compress = false)
+    {
+        $this->connection = $connection;
+        $this->compress = $compress ? MEMCACHE_COMPRESSED : 0;
+    }
+
+    /**
+     * Store association until its expiration time in memcached. 
+     * Overwrites any existing association with same server_url and 
+     * handle. Handles list of associations for every server. 
+     */
+    function storeAssociation($server_url, $association)
+    {
+        // create memcached keys for association itself 
+        // and list of associations for this server
+        $associationKey = $this->associationKey($server_url, 
+            $association->handle);
+        $serverKey = $this->associationServerKey($server_url);
+        
+        // get list of associations 
+        $serverAssociations = $this->connection->get($serverKey);
+        
+        // if no such list, initialize it with empty array
+        if (!$serverAssociations) {
+            $serverAssociations = array();
+        }
+        // and store given association key in it
+        $serverAssociations[$association->issued] = $associationKey;
+        
+        // save associations' keys list 
+        $this->connection->set(
+            $serverKey,
+            $serverAssociations,
+            $this->compress
+        );
+        // save association itself
+        $this->connection->set(
+            $associationKey,
+            $association, 
+            $this->compress, 
+            $association->issued + $association->lifetime);
+    }
+
+    /**
+     * Read association from memcached. If no handle given 
+     * and multiple associations found, returns latest issued
+     */
+    function getAssociation($server_url, $handle = null)
+    {
+        // simple case: handle given
+        if ($handle !== null) {
+            // get association, return null if failed
+            $association = $this->connection->get(
+                $this->associationKey($server_url, $handle));
+            return $association ? $association : null;
+        }
+        
+        // no handle given, working with list
+        // create key for list of associations
+        $serverKey = $this->associationServerKey($server_url);
+        
+        // get list of associations
+        $serverAssociations = $this->connection->get($serverKey);
+        // return null if failed or got empty list
+        if (!$serverAssociations) {
+            return null;
+        }
+        
+        // get key of most recently issued association
+        $keys = array_keys($serverAssociations);
+        sort($keys);
+        $lastKey = $serverAssociations[array_pop($keys)];
+        
+        // get association, return null if failed
+        $association = $this->connection->get($lastKey);
+        return $association ? $association : null;
+    }
+
+    /**
+     * Immediately delete association from memcache.
+     */
+    function removeAssociation($server_url, $handle)
+    {
+        // create memcached keys for association itself 
+        // and list of associations for this server
+        $serverKey = $this->associationServerKey($server_url);
+        $associationKey = $this->associationKey($server_url, 
+            $handle);
+        
+        // get list of associations
+        $serverAssociations = $this->connection->get($serverKey);
+        // return null if failed or got empty list
+        if (!$serverAssociations) {
+            return false;
+        }
+        
+        // ensure that given association key exists in list
+        $serverAssociations = array_flip($serverAssociations);
+        if (!array_key_exists($associationKey, $serverAssociations)) {
+            return false;
+        }
+        
+        // remove given association key from list
+        unset($serverAssociations[$associationKey]);
+        $serverAssociations = array_flip($serverAssociations);
+        
+        // save updated list
+        $this->connection->set(
+            $serverKey,
+            $serverAssociations,
+            $this->compress
+        );
+
+        // delete association 
+        return $this->connection->delete($associationKey);
+    }
+
+    /**
+     * Create nonce for server and salt, expiring after 
+     * $Auth_OpenID_SKEW seconds.
+     */
+    function useNonce($server_url, $timestamp, $salt)
+    {
+        global $Auth_OpenID_SKEW;
+        
+        // save one request to memcache when nonce obviously expired 
+        if (abs($timestamp - time()) > $Auth_OpenID_SKEW) {
+            return false;
+        }
+        
+        // returns false when nonce already exists
+        // otherwise adds nonce
+        return $this->connection->add(
+            'openid_nonce_' . sha1($server_url) . '_' . sha1($salt), 
+            1, // any value here 
+            $this->compress, 
+            $Auth_OpenID_SKEW);
+    }
+    
+    /**
+     * Memcache key is prefixed with 'openid_association_' string. 
+     */
+    function associationKey($server_url, $handle = null) 
+    {
+        return 'openid_association_' . sha1($server_url) . '_' . sha1($handle);
+    }
+    
+    /**
+     * Memcache key is prefixed with 'openid_association_' string. 
+     */
+    function associationServerKey($server_url) 
+    {
+        return 'openid_association_server_' . sha1($server_url);
+    }
+    
+    /**
+     * Report that this storage doesn't support cleanup
+     */
+    function supportsCleanup()
+    {
+        return false;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/Message.php b/inc/lib/Auth/OpenID/Message.php
new file mode 100644 (file)
index 0000000..5ab115a
--- /dev/null
@@ -0,0 +1,920 @@
+<?php
+
+/**
+ * Extension argument processing code
+ *
+ * @package OpenID
+ */
+
+/**
+ * Import tools needed to deal with messages.
+ */
+require_once 'Auth/OpenID.php';
+require_once 'Auth/OpenID/KVForm.php';
+require_once 'Auth/Yadis/XML.php';
+require_once 'Auth/OpenID/Consumer.php'; // For Auth_OpenID_FailureResponse
+
+// This doesn't REALLY belong here, but where is better?
+define('Auth_OpenID_IDENTIFIER_SELECT',
+       "http://specs.openid.net/auth/2.0/identifier_select");
+
+// URI for Simple Registration extension, the only commonly deployed
+// OpenID 1.x extension, and so a special case
+define('Auth_OpenID_SREG_URI', 'http://openid.net/sreg/1.0');
+
+// The OpenID 1.X namespace URI
+define('Auth_OpenID_OPENID1_NS', 'http://openid.net/signon/1.0');
+define('Auth_OpenID_THE_OTHER_OPENID1_NS', 'http://openid.net/signon/1.1');
+
+function Auth_OpenID_isOpenID1($ns)
+{
+    return ($ns == Auth_OpenID_THE_OTHER_OPENID1_NS) ||
+        ($ns == Auth_OpenID_OPENID1_NS);
+}
+
+// The OpenID 2.0 namespace URI
+define('Auth_OpenID_OPENID2_NS', 'http://specs.openid.net/auth/2.0');
+
+// The namespace consisting of pairs with keys that are prefixed with
+// "openid."  but not in another namespace.
+define('Auth_OpenID_NULL_NAMESPACE', 'Null namespace');
+
+// The null namespace, when it is an allowed OpenID namespace
+define('Auth_OpenID_OPENID_NS', 'OpenID namespace');
+
+// The top-level namespace, excluding all pairs with keys that start
+// with "openid."
+define('Auth_OpenID_BARE_NS', 'Bare namespace');
+
+// Sentinel for Message implementation to indicate that getArg should
+// return null instead of returning a default.
+define('Auth_OpenID_NO_DEFAULT', 'NO DEFAULT ALLOWED');
+
+// Limit, in bytes, of identity provider and return_to URLs, including
+// response payload.  See OpenID 1.1 specification, Appendix D.
+define('Auth_OpenID_OPENID1_URL_LIMIT', 2047);
+
+// All OpenID protocol fields.  Used to check namespace aliases.
+global $Auth_OpenID_OPENID_PROTOCOL_FIELDS;
+$Auth_OpenID_OPENID_PROTOCOL_FIELDS = array(
+    'ns', 'mode', 'error', 'return_to', 'contact', 'reference',
+    'signed', 'assoc_type', 'session_type', 'dh_modulus', 'dh_gen',
+    'dh_consumer_public', 'claimed_id', 'identity', 'realm',
+    'invalidate_handle', 'op_endpoint', 'response_nonce', 'sig',
+    'assoc_handle', 'trust_root', 'openid');
+
+// Global namespace / alias registration map.  See
+// Auth_OpenID_registerNamespaceAlias.
+global $Auth_OpenID_registered_aliases;
+$Auth_OpenID_registered_aliases = array();
+
+/**
+ * Registers a (namespace URI, alias) mapping in a global namespace
+ * alias map.  Raises NamespaceAliasRegistrationError if either the
+ * namespace URI or alias has already been registered with a different
+ * value.  This function is required if you want to use a namespace
+ * with an OpenID 1 message.
+ */
+function Auth_OpenID_registerNamespaceAlias($namespace_uri, $alias)
+{
+    global $Auth_OpenID_registered_aliases;
+
+    if (Auth_OpenID::arrayGet($Auth_OpenID_registered_aliases,
+                              $alias) == $namespace_uri) {
+        return true;
+    }
+
+    if (in_array($namespace_uri,
+                 array_values($Auth_OpenID_registered_aliases))) {
+        return false;
+    }
+
+    if (in_array($alias, array_keys($Auth_OpenID_registered_aliases))) {
+        return false;
+    }
+
+    $Auth_OpenID_registered_aliases[$alias] = $namespace_uri;
+    return true;
+}
+
+/**
+ * Removes a (namespace_uri, alias) registration from the global
+ * namespace alias map.  Returns true if the removal succeeded; false
+ * if not (if the mapping did not exist).
+ */
+function Auth_OpenID_removeNamespaceAlias($namespace_uri, $alias)
+{
+    global $Auth_OpenID_registered_aliases;
+
+    if (Auth_OpenID::arrayGet($Auth_OpenID_registered_aliases,
+                              $alias) === $namespace_uri) {
+        unset($Auth_OpenID_registered_aliases[$alias]);
+        return true;
+    }
+
+    return false;
+}
+
+/**
+ * An Auth_OpenID_Mapping maintains a mapping from arbitrary keys to
+ * arbitrary values.  (This is unlike an ordinary PHP array, whose
+ * keys may be only simple scalars.)
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Mapping {
+    /**
+     * Initialize a mapping.  If $classic_array is specified, its keys
+     * and values are used to populate the mapping.
+     */
+    function Auth_OpenID_Mapping($classic_array = null)
+    {
+        $this->keys = array();
+        $this->values = array();
+
+        if (is_array($classic_array)) {
+            foreach ($classic_array as $key => $value) {
+                $this->set($key, $value);
+            }
+        }
+    }
+
+    /**
+     * Returns true if $thing is an Auth_OpenID_Mapping object; false
+     * if not.
+     */
+    function isA($thing)
+    {
+        return (is_object($thing) &&
+                strtolower(get_class($thing)) == 'auth_openid_mapping');
+    }
+
+    /**
+     * Returns an array of the keys in the mapping.
+     */
+    function keys()
+    {
+        return $this->keys;
+    }
+
+    /**
+     * Returns an array of values in the mapping.
+     */
+    function values()
+    {
+        return $this->values;
+    }
+
+    /**
+     * Returns an array of (key, value) pairs in the mapping.
+     */
+    function items()
+    {
+        $temp = array();
+
+        for ($i = 0; $i < count($this->keys); $i++) {
+            $temp[] = array($this->keys[$i],
+                            $this->values[$i]);
+        }
+        return $temp;
+    }
+
+    /**
+     * Returns the "length" of the mapping, or the number of keys.
+     */
+    function len()
+    {
+        return count($this->keys);
+    }
+
+    /**
+     * Sets a key-value pair in the mapping.  If the key already
+     * exists, its value is replaced with the new value.
+     */
+    function set($key, $value)
+    {
+        $index = array_search($key, $this->keys);
+
+        if ($index !== false) {
+            $this->values[$index] = $value;
+        } else {
+            $this->keys[] = $key;
+            $this->values[] = $value;
+        }
+    }
+
+    /**
+     * Gets a specified value from the mapping, associated with the
+     * specified key.  If the key does not exist in the mapping,
+     * $default is returned instead.
+     */
+    function get($key, $default = null)
+    {
+        $index = array_search($key, $this->keys);
+
+        if ($index !== false) {
+            return $this->values[$index];
+        } else {
+            return $default;
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _reflow()
+    {
+        // PHP is broken yet again.  Sort the arrays to remove the
+        // hole in the numeric indexes that make up the array.
+        $old_keys = $this->keys;
+        $old_values = $this->values;
+
+        $this->keys = array();
+        $this->values = array();
+
+        foreach ($old_keys as $k) {
+            $this->keys[] = $k;
+        }
+
+        foreach ($old_values as $v) {
+            $this->values[] = $v;
+        }
+    }
+
+    /**
+     * Deletes a key-value pair from the mapping with the specified
+     * key.
+     */
+    function del($key)
+    {
+        $index = array_search($key, $this->keys);
+
+        if ($index !== false) {
+            unset($this->keys[$index]);
+            unset($this->values[$index]);
+            $this->_reflow();
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the specified value has a key in the mapping;
+     * false if not.
+     */
+    function contains($value)
+    {
+        return (array_search($value, $this->keys) !== false);
+    }
+}
+
+/**
+ * Maintains a bijective map between namespace uris and aliases.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_NamespaceMap {
+    function Auth_OpenID_NamespaceMap()
+    {
+        $this->alias_to_namespace = new Auth_OpenID_Mapping();
+        $this->namespace_to_alias = new Auth_OpenID_Mapping();
+        $this->implicit_namespaces = array();
+    }
+
+    function getAlias($namespace_uri)
+    {
+        return $this->namespace_to_alias->get($namespace_uri);
+    }
+
+    function getNamespaceURI($alias)
+    {
+        return $this->alias_to_namespace->get($alias);
+    }
+
+    function iterNamespaceURIs()
+    {
+        // Return an iterator over the namespace URIs
+        return $this->namespace_to_alias->keys();
+    }
+
+    function iterAliases()
+    {
+        // Return an iterator over the aliases"""
+        return $this->alias_to_namespace->keys();
+    }
+
+    function iteritems()
+    {
+        return $this->namespace_to_alias->items();
+    }
+
+    function isImplicit($namespace_uri)
+    {
+        return in_array($namespace_uri, $this->implicit_namespaces);
+    }
+
+    function addAlias($namespace_uri, $desired_alias, $implicit=false)
+    {
+        // Add an alias from this namespace URI to the desired alias
+        global $Auth_OpenID_OPENID_PROTOCOL_FIELDS;
+
+        // Check that desired_alias is not an openid protocol field as
+        // per the spec.
+        if (in_array($desired_alias, $Auth_OpenID_OPENID_PROTOCOL_FIELDS)) {
+            Auth_OpenID::log("\"%s\" is not an allowed namespace alias",
+                            $desired_alias);
+            return null;
+        }
+
+        // Check that desired_alias does not contain a period as per
+        // the spec.
+        if (strpos($desired_alias, '.') !== false) {
+            Auth_OpenID::log('"%s" must not contain a dot', $desired_alias);
+            return null;
+        }
+
+        // Check that there is not a namespace already defined for the
+        // desired alias
+        $current_namespace_uri =
+            $this->alias_to_namespace->get($desired_alias);
+
+        if (($current_namespace_uri !== null) &&
+            ($current_namespace_uri != $namespace_uri)) {
+            Auth_OpenID::log('Cannot map "%s" because previous mapping exists',
+                            $namespace_uri);
+            return null;
+        }
+
+        // Check that there is not already a (different) alias for
+        // this namespace URI
+        $alias = $this->namespace_to_alias->get($namespace_uri);
+
+        if (($alias !== null) && ($alias != $desired_alias)) {
+            Auth_OpenID::log('Cannot map %s to alias %s. ' .
+                            'It is already mapped to alias %s',
+                            $namespace_uri, $desired_alias, $alias);
+            return null;
+        }
+
+        assert((Auth_OpenID_NULL_NAMESPACE === $desired_alias) ||
+               is_string($desired_alias));
+
+        $this->alias_to_namespace->set($desired_alias, $namespace_uri);
+        $this->namespace_to_alias->set($namespace_uri, $desired_alias);
+        if ($implicit) {
+            array_push($this->implicit_namespaces, $namespace_uri);
+        }
+
+        return $desired_alias;
+    }
+
+    function add($namespace_uri)
+    {
+        // Add this namespace URI to the mapping, without caring what
+        // alias it ends up with
+
+        // See if this namespace is already mapped to an alias
+        $alias = $this->namespace_to_alias->get($namespace_uri);
+
+        if ($alias !== null) {
+            return $alias;
+        }
+
+        // Fall back to generating a numerical alias
+        $i = 0;
+        while (1) {
+            $alias = 'ext' . strval($i);
+            if ($this->addAlias($namespace_uri, $alias) === null) {
+                $i += 1;
+            } else {
+                return $alias;
+            }
+        }
+
+        // Should NEVER be reached!
+        return null;
+    }
+
+    function contains($namespace_uri)
+    {
+        return $this->isDefined($namespace_uri);
+    }
+
+    function isDefined($namespace_uri)
+    {
+        return $this->namespace_to_alias->contains($namespace_uri);
+    }
+}
+
+/**
+ * In the implementation of this object, null represents the global
+ * namespace as well as a namespace with no key.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Message {
+
+    function Auth_OpenID_Message($openid_namespace = null)
+    {
+        // Create an empty Message
+        $this->allowed_openid_namespaces = array(
+                               Auth_OpenID_OPENID1_NS,
+                               Auth_OpenID_THE_OTHER_OPENID1_NS,
+                               Auth_OpenID_OPENID2_NS);
+
+        $this->args = new Auth_OpenID_Mapping();
+        $this->namespaces = new Auth_OpenID_NamespaceMap();
+        if ($openid_namespace === null) {
+            $this->_openid_ns_uri = null;
+        } else {
+            $implicit = Auth_OpenID_isOpenID1($openid_namespace);
+            $this->setOpenIDNamespace($openid_namespace, $implicit);
+        }
+    }
+
+    function isOpenID1()
+    {
+        return Auth_OpenID_isOpenID1($this->getOpenIDNamespace());
+    }
+
+    function isOpenID2()
+    {
+        return $this->getOpenIDNamespace() == Auth_OpenID_OPENID2_NS;
+    }
+
+    function fromPostArgs($args)
+    {
+        // Construct a Message containing a set of POST arguments
+        $obj = new Auth_OpenID_Message();
+
+        // Partition into "openid." args and bare args
+        $openid_args = array();
+        foreach ($args as $key => $value) {
+
+            if (is_array($value)) {
+                return null;
+            }
+
+            $parts = explode('.', $key, 2);
+
+            if (count($parts) == 2) {
+                list($prefix, $rest) = $parts;
+            } else {
+                $prefix = null;
+            }
+
+            if ($prefix != 'openid') {
+                $obj->args->set(array(Auth_OpenID_BARE_NS, $key), $value);
+            } else {
+                $openid_args[$rest] = $value;
+            }
+        }
+
+        if ($obj->_fromOpenIDArgs($openid_args)) {
+            return $obj;
+        } else {
+            return null;
+        }
+    }
+
+    function fromOpenIDArgs($openid_args)
+    {
+        // Takes an array.
+
+        // Construct a Message from a parsed KVForm message
+        $obj = new Auth_OpenID_Message();
+        if ($obj->_fromOpenIDArgs($openid_args)) {
+            return $obj;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _fromOpenIDArgs($openid_args)
+    {
+        global $Auth_OpenID_registered_aliases;
+
+        // Takes an Auth_OpenID_Mapping instance OR an array.
+
+        if (!Auth_OpenID_Mapping::isA($openid_args)) {
+            $openid_args = new Auth_OpenID_Mapping($openid_args);
+        }
+
+        $ns_args = array();
+
+        // Resolve namespaces
+        foreach ($openid_args->items() as $pair) {
+            list($rest, $value) = $pair;
+
+            $parts = explode('.', $rest, 2);
+
+            if (count($parts) == 2) {
+                list($ns_alias, $ns_key) = $parts;
+            } else {
+                $ns_alias = Auth_OpenID_NULL_NAMESPACE;
+                $ns_key = $rest;
+            }
+
+            if ($ns_alias == 'ns') {
+                if ($this->namespaces->addAlias($value, $ns_key) === null) {
+                    return false;
+                }
+            } else if (($ns_alias == Auth_OpenID_NULL_NAMESPACE) &&
+                       ($ns_key == 'ns')) {
+                // null namespace
+                if ($this->setOpenIDNamespace($value, false) === false) {
+                    return false;
+                }
+            } else {
+                $ns_args[] = array($ns_alias, $ns_key, $value);
+            }
+        }
+
+        if (!$this->getOpenIDNamespace()) {
+            if ($this->setOpenIDNamespace(Auth_OpenID_OPENID1_NS, true) ===
+                false) {
+                return false;
+            }
+        }
+
+        // Actually put the pairs into the appropriate namespaces
+        foreach ($ns_args as $triple) {
+            list($ns_alias, $ns_key, $value) = $triple;
+            $ns_uri = $this->namespaces->getNamespaceURI($ns_alias);
+            if ($ns_uri === null) {
+                $ns_uri = $this->_getDefaultNamespace($ns_alias);
+                if ($ns_uri === null) {
+
+                    $ns_uri = Auth_OpenID_OPENID_NS;
+                    $ns_key = sprintf('%s.%s', $ns_alias, $ns_key);
+                } else {
+                    $this->namespaces->addAlias($ns_uri, $ns_alias, true);
+                }
+            }
+
+            $this->setArg($ns_uri, $ns_key, $value);
+        }
+
+        return true;
+    }
+
+    function _getDefaultNamespace($mystery_alias)
+    {
+        global $Auth_OpenID_registered_aliases;
+        if ($this->isOpenID1()) {
+            return @$Auth_OpenID_registered_aliases[$mystery_alias];
+        }
+        return null;
+    }
+
+    function setOpenIDNamespace($openid_ns_uri, $implicit)
+    {
+        if (!in_array($openid_ns_uri, $this->allowed_openid_namespaces)) {
+            Auth_OpenID::log('Invalid null namespace: "%s"', $openid_ns_uri);
+            return false;
+        }
+
+        $succeeded = $this->namespaces->addAlias($openid_ns_uri,
+                                                 Auth_OpenID_NULL_NAMESPACE,
+                                                 $implicit);
+        if ($succeeded === false) {
+            return false;
+        }
+
+        $this->_openid_ns_uri = $openid_ns_uri;
+
+        return true;
+    }
+
+    function getOpenIDNamespace()
+    {
+        return $this->_openid_ns_uri;
+    }
+
+    function fromKVForm($kvform_string)
+    {
+        // Create a Message from a KVForm string
+        return Auth_OpenID_Message::fromOpenIDArgs(
+                     Auth_OpenID_KVForm::toArray($kvform_string));
+    }
+
+    function copy()
+    {
+        return $this;
+    }
+
+    function toPostArgs()
+    {
+        // Return all arguments with openid. in front of namespaced
+        // arguments.
+
+        $args = array();
+
+        // Add namespace definitions to the output
+        foreach ($this->namespaces->iteritems() as $pair) {
+            list($ns_uri, $alias) = $pair;
+            if ($this->namespaces->isImplicit($ns_uri)) {
+                continue;
+            }
+            if ($alias == Auth_OpenID_NULL_NAMESPACE) {
+                $ns_key = 'openid.ns';
+            } else {
+                $ns_key = 'openid.ns.' . $alias;
+            }
+            $args[$ns_key] = $ns_uri;
+        }
+
+        foreach ($this->args->items() as $pair) {
+            list($ns_parts, $value) = $pair;
+            list($ns_uri, $ns_key) = $ns_parts;
+            $key = $this->getKey($ns_uri, $ns_key);
+            $args[$key] = $value;
+        }
+
+        return $args;
+    }
+
+    function toArgs()
+    {
+        // Return all namespaced arguments, failing if any
+        // non-namespaced arguments exist.
+        $post_args = $this->toPostArgs();
+        $kvargs = array();
+        foreach ($post_args as $k => $v) {
+            if (strpos($k, 'openid.') !== 0) {
+                // raise ValueError(
+                //   'This message can only be encoded as a POST, because it '
+                //   'contains arguments that are not prefixed with "openid."')
+                return null;
+            } else {
+                $kvargs[substr($k, 7)] = $v;
+            }
+        }
+
+        return $kvargs;
+    }
+
+    function toFormMarkup($action_url, $form_tag_attrs = null,
+                          $submit_text = "Continue")
+    {
+        $form = "<form accept-charset=\"UTF-8\" ".
+            "enctype=\"application/x-www-form-urlencoded\"";
+
+        if (!$form_tag_attrs) {
+            $form_tag_attrs = array();
+        }
+
+        $form_tag_attrs['action'] = $action_url;
+        $form_tag_attrs['method'] = 'post';
+
+        unset($form_tag_attrs['enctype']);
+        unset($form_tag_attrs['accept-charset']);
+
+        if ($form_tag_attrs) {
+            foreach ($form_tag_attrs as $name => $attr) {
+                $form .= sprintf(" %s=\"%s\"", $name, $attr);
+            }
+        }
+
+        $form .= ">\n";
+
+        foreach ($this->toPostArgs() as $name => $value) {
+            $form .= sprintf(
+                        "<input type=\"hidden\" name=\"%s\" value=\"%s\" />\n",
+                        $name, $value);
+        }
+
+        $form .= sprintf("<input type=\"submit\" value=\"%s\" />\n",
+                         $submit_text);
+
+        $form .= "</form>\n";
+
+        return $form;
+    }
+
+    function toURL($base_url)
+    {
+        // Generate a GET URL with the parameters in this message
+        // attached as query parameters.
+        return Auth_OpenID::appendArgs($base_url, $this->toPostArgs());
+    }
+
+    function toKVForm()
+    {
+        // Generate a KVForm string that contains the parameters in
+        // this message. This will fail if the message contains
+        // arguments outside of the 'openid.' prefix.
+        return Auth_OpenID_KVForm::fromArray($this->toArgs());
+    }
+
+    function toURLEncoded()
+    {
+        // Generate an x-www-urlencoded string
+        $args = array();
+
+        foreach ($this->toPostArgs() as $k => $v) {
+            $args[] = array($k, $v);
+        }
+
+        sort($args);
+        return Auth_OpenID::httpBuildQuery($args);
+    }
+
+    /**
+     * @access private
+     */
+    function _fixNS($namespace)
+    {
+        // Convert an input value into the internally used values of
+        // this object
+
+        if ($namespace == Auth_OpenID_OPENID_NS) {
+            if ($this->_openid_ns_uri === null) {
+                return new Auth_OpenID_FailureResponse(null,
+                    'OpenID namespace not set');
+            } else {
+                $namespace = $this->_openid_ns_uri;
+            }
+        }
+
+        if (($namespace != Auth_OpenID_BARE_NS) &&
+              (!is_string($namespace))) {
+            //TypeError
+            $err_msg = sprintf("Namespace must be Auth_OpenID_BARE_NS, ".
+                              "Auth_OpenID_OPENID_NS or a string. got %s",
+                              print_r($namespace, true));
+            return new Auth_OpenID_FailureResponse(null, $err_msg);
+        }
+
+        if (($namespace != Auth_OpenID_BARE_NS) &&
+            (strpos($namespace, ':') === false)) {
+            // fmt = 'OpenID 2.0 namespace identifiers SHOULD be URIs. Got %r'
+            // warnings.warn(fmt % (namespace,), DeprecationWarning)
+
+            if ($namespace == 'sreg') {
+                // fmt = 'Using %r instead of "sreg" as namespace'
+                // warnings.warn(fmt % (SREG_URI,), DeprecationWarning,)
+                return Auth_OpenID_SREG_URI;
+            }
+        }
+
+        return $namespace;
+    }
+
+    function hasKey($namespace, $ns_key)
+    {
+        $namespace = $this->_fixNS($namespace);
+        if (Auth_OpenID::isFailure($namespace)) {
+            // XXX log me
+            return false;
+        } else {
+            return $this->args->contains(array($namespace, $ns_key));
+        }
+    }
+
+    function getKey($namespace, $ns_key)
+    {
+        // Get the key for a particular namespaced argument
+        $namespace = $this->_fixNS($namespace);
+        if (Auth_OpenID::isFailure($namespace)) {
+            return $namespace;
+        }
+        if ($namespace == Auth_OpenID_BARE_NS) {
+            return $ns_key;
+        }
+
+        $ns_alias = $this->namespaces->getAlias($namespace);
+
+        // No alias is defined, so no key can exist
+        if ($ns_alias === null) {
+            return null;
+        }
+
+        if ($ns_alias == Auth_OpenID_NULL_NAMESPACE) {
+            $tail = $ns_key;
+        } else {
+            $tail = sprintf('%s.%s', $ns_alias, $ns_key);
+        }
+
+        return 'openid.' . $tail;
+    }
+
+    function getArg($namespace, $key, $default = null)
+    {
+        // Get a value for a namespaced key.
+        $namespace = $this->_fixNS($namespace);
+
+        if (Auth_OpenID::isFailure($namespace)) {
+            return $namespace;
+        } else {
+            if ((!$this->args->contains(array($namespace, $key))) &&
+              ($default == Auth_OpenID_NO_DEFAULT)) {
+                $err_msg = sprintf("Namespace %s missing required field %s",
+                                   $namespace, $key);
+                return new Auth_OpenID_FailureResponse(null, $err_msg);
+            } else {
+                return $this->args->get(array($namespace, $key), $default);
+            }
+        }
+    }
+
+    function getArgs($namespace)
+    {
+        // Get the arguments that are defined for this namespace URI
+
+        $namespace = $this->_fixNS($namespace);
+        if (Auth_OpenID::isFailure($namespace)) {
+            return $namespace;
+        } else {
+            $stuff = array();
+            foreach ($this->args->items() as $pair) {
+                list($key, $value) = $pair;
+                list($pair_ns, $ns_key) = $key;
+                if ($pair_ns == $namespace) {
+                    $stuff[$ns_key] = $value;
+                }
+            }
+
+            return $stuff;
+        }
+    }
+
+    function updateArgs($namespace, $updates)
+    {
+        // Set multiple key/value pairs in one call
+
+        $namespace = $this->_fixNS($namespace);
+
+        if (Auth_OpenID::isFailure($namespace)) {
+            return $namespace;
+        } else {
+            foreach ($updates as $k => $v) {
+                $this->setArg($namespace, $k, $v);
+            }
+            return true;
+        }
+    }
+
+    function setArg($namespace, $key, $value)
+    {
+        // Set a single argument in this namespace
+        $namespace = $this->_fixNS($namespace);
+
+        if (Auth_OpenID::isFailure($namespace)) {
+            return $namespace;
+        } else {
+            $this->args->set(array($namespace, $key), $value);
+            if ($namespace !== Auth_OpenID_BARE_NS) {
+                $this->namespaces->add($namespace);
+            }
+            return true;
+        }
+    }
+
+    function delArg($namespace, $key)
+    {
+        $namespace = $this->_fixNS($namespace);
+
+        if (Auth_OpenID::isFailure($namespace)) {
+            return $namespace;
+        } else {
+            return $this->args->del(array($namespace, $key));
+        }
+    }
+
+    function getAliasedArg($aliased_key, $default = null)
+    {
+        if ($aliased_key == 'ns') {
+            // Return the namespace URI for the OpenID namespace
+            return $this->getOpenIDNamespace();
+        }
+
+        $parts = explode('.', $aliased_key, 2);
+
+        if (count($parts) != 2) {
+            $ns = null;
+        } else {
+            list($alias, $key) = $parts;
+
+            if ($alias == 'ns') {
+              // Return the namespace URI for a namespace alias
+              // parameter.
+              return $this->namespaces->getNamespaceURI($key);
+            } else {
+              $ns = $this->namespaces->getNamespaceURI($alias);
+            }
+        }
+
+        if ($ns === null) {
+            $key = $aliased_key;
+            $ns = $this->getOpenIDNamespace();
+        }
+
+        return $this->getArg($ns, $key, $default);
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/MySQLStore.php b/inc/lib/Auth/OpenID/MySQLStore.php
new file mode 100644 (file)
index 0000000..eb08af0
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * A MySQL store.
+ *
+ * @package OpenID
+ */
+
+/**
+ * Require the base class file.
+ */
+require_once "Auth/OpenID/SQLStore.php";
+
+/**
+ * An SQL store that uses MySQL as its backend.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_MySQLStore extends Auth_OpenID_SQLStore {
+    /**
+     * @access private
+     */
+    function setSQL()
+    {
+        $this->sql['nonce_table'] =
+            "CREATE TABLE %s (\n".
+            "  server_url VARCHAR(2047) NOT NULL,\n".
+            "  timestamp INTEGER NOT NULL,\n".
+            "  salt CHAR(40) NOT NULL,\n".
+            "  UNIQUE (server_url(255), timestamp, salt)\n".
+            ") ENGINE=InnoDB";
+
+        $this->sql['assoc_table'] =
+            "CREATE TABLE %s (\n".
+            "  server_url BLOB NOT NULL,\n".
+            "  handle VARCHAR(255) NOT NULL,\n".
+            "  secret BLOB NOT NULL,\n".
+            "  issued INTEGER NOT NULL,\n".
+            "  lifetime INTEGER NOT NULL,\n".
+            "  assoc_type VARCHAR(64) NOT NULL,\n".
+            "  PRIMARY KEY (server_url(255), handle)\n".
+            ") ENGINE=InnoDB";
+
+        $this->sql['set_assoc'] =
+            "REPLACE INTO %s (server_url, handle, secret, issued,\n".
+            "  lifetime, assoc_type) VALUES (?, ?, !, ?, ?, ?)";
+
+        $this->sql['get_assocs'] =
+            "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ".
+            "WHERE server_url = ?";
+
+        $this->sql['get_assoc'] =
+            "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ".
+            "WHERE server_url = ? AND handle = ?";
+
+        $this->sql['remove_assoc'] =
+            "DELETE FROM %s WHERE server_url = ? AND handle = ?";
+
+        $this->sql['add_nonce'] =
+            "INSERT INTO %s (server_url, timestamp, salt) VALUES (?, ?, ?)";
+
+        $this->sql['clean_nonce'] =
+            "DELETE FROM %s WHERE timestamp < ?";
+
+        $this->sql['clean_assoc'] =
+            "DELETE FROM %s WHERE issued + lifetime < ?";
+    }
+
+    /**
+     * @access private
+     */
+    function blobEncode($blob)
+    {
+        return "0x" . bin2hex($blob);
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/Nonce.php b/inc/lib/Auth/OpenID/Nonce.php
new file mode 100644 (file)
index 0000000..effecac
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+
+/**
+ * Nonce-related functionality.
+ *
+ * @package OpenID
+ */
+
+/**
+ * Need CryptUtil to generate random strings.
+ */
+require_once 'Auth/OpenID/CryptUtil.php';
+
+/**
+ * This is the characters that the nonces are made from.
+ */
+define('Auth_OpenID_Nonce_CHRS',"abcdefghijklmnopqrstuvwxyz" .
+       "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
+
+// Keep nonces for five hours (allow five hours for the combination of
+// request time and clock skew). This is probably way more than is
+// necessary, but there is not much overhead in storing nonces.
+global $Auth_OpenID_SKEW;
+$Auth_OpenID_SKEW = 60 * 60 * 5;
+
+define('Auth_OpenID_Nonce_REGEX',
+       '/(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)Z(.*)/');
+
+define('Auth_OpenID_Nonce_TIME_FMT',
+       '%Y-%m-%dT%H:%M:%SZ');
+
+function Auth_OpenID_splitNonce($nonce_string)
+{
+    // Extract a timestamp from the given nonce string
+    $result = preg_match(Auth_OpenID_Nonce_REGEX, $nonce_string, $matches);
+    if ($result != 1 || count($matches) != 8) {
+        return null;
+    }
+
+    list($unused,
+         $tm_year,
+         $tm_mon,
+         $tm_mday,
+         $tm_hour,
+         $tm_min,
+         $tm_sec,
+         $uniquifier) = $matches;
+
+    $timestamp =
+        @gmmktime($tm_hour, $tm_min, $tm_sec, $tm_mon, $tm_mday, $tm_year);
+
+    if ($timestamp === false || $timestamp < 0) {
+        return null;
+    }
+
+    return array($timestamp, $uniquifier);
+}
+
+function Auth_OpenID_checkTimestamp($nonce_string,
+                                    $allowed_skew = null,
+                                    $now = null)
+{
+    // Is the timestamp that is part of the specified nonce string
+    // within the allowed clock-skew of the current time?
+    global $Auth_OpenID_SKEW;
+
+    if ($allowed_skew === null) {
+        $allowed_skew = $Auth_OpenID_SKEW;
+    }
+
+    $parts = Auth_OpenID_splitNonce($nonce_string);
+    if ($parts == null) {
+        return false;
+    }
+
+    if ($now === null) {
+        $now = time();
+    }
+
+    $stamp = $parts[0];
+
+    // Time after which we should not use the nonce
+    $past = $now - $allowed_skew;
+
+    // Time that is too far in the future for us to allow
+    $future = $now + $allowed_skew;
+
+    // the stamp is not too far in the future and is not too far
+    // in the past
+    return (($past <= $stamp) && ($stamp <= $future));
+}
+
+function Auth_OpenID_mkNonce($when = null)
+{
+    // Generate a nonce with the current timestamp
+    $salt = Auth_OpenID_CryptUtil::randomString(
+        6, Auth_OpenID_Nonce_CHRS);
+    if ($when === null) {
+        // It's safe to call time() with no arguments; it returns a
+        // GMT unix timestamp on PHP 4 and PHP 5.  gmmktime() with no
+        // args returns a local unix timestamp on PHP 4, so don't use
+        // that.
+        $when = time();
+    }
+    $time_str = gmstrftime(Auth_OpenID_Nonce_TIME_FMT, $when);
+    return $time_str . $salt;
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/PAPE.php b/inc/lib/Auth/OpenID/PAPE.php
new file mode 100644 (file)
index 0000000..62cba8a
--- /dev/null
@@ -0,0 +1,301 @@
+<?php
+
+/**
+ * An implementation of the OpenID Provider Authentication Policy
+ *  Extension 1.0
+ *
+ * See:
+ * http://openid.net/developers/specs/
+ */
+
+require_once "Auth/OpenID/Extension.php";
+
+define('Auth_OpenID_PAPE_NS_URI',
+       "http://specs.openid.net/extensions/pape/1.0");
+
+define('PAPE_AUTH_MULTI_FACTOR_PHYSICAL',
+       'http://schemas.openid.net/pape/policies/2007/06/multi-factor-physical');
+define('PAPE_AUTH_MULTI_FACTOR',
+       'http://schemas.openid.net/pape/policies/2007/06/multi-factor');
+define('PAPE_AUTH_PHISHING_RESISTANT',
+       'http://schemas.openid.net/pape/policies/2007/06/phishing-resistant');
+
+define('PAPE_TIME_VALIDATOR',
+       '^[0-9]{4,4}-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z$');
+/**
+ * A Provider Authentication Policy request, sent from a relying party
+ * to a provider
+ *
+ * preferred_auth_policies: The authentication policies that
+ * the relying party prefers
+ *
+ * max_auth_age: The maximum time, in seconds, that the relying party
+ * wants to allow to have elapsed before the user must re-authenticate
+ */
+class Auth_OpenID_PAPE_Request extends Auth_OpenID_Extension {
+
+    var $ns_alias = 'pape';
+    var $ns_uri = Auth_OpenID_PAPE_NS_URI;
+
+    function Auth_OpenID_PAPE_Request($preferred_auth_policies=null,
+                                      $max_auth_age=null)
+    {
+        if ($preferred_auth_policies === null) {
+            $preferred_auth_policies = array();
+        }
+
+        $this->preferred_auth_policies = $preferred_auth_policies;
+        $this->max_auth_age = $max_auth_age;
+    }
+
+    /**
+     * Add an acceptable authentication policy URI to this request
+     *
+     * This method is intended to be used by the relying party to add
+     * acceptable authentication types to the request.
+     *
+     * policy_uri: The identifier for the preferred type of
+     * authentication.
+     */
+    function addPolicyURI($policy_uri)
+    {
+        if (!in_array($policy_uri, $this->preferred_auth_policies)) {
+            $this->preferred_auth_policies[] = $policy_uri;
+        }
+    }
+
+    function getExtensionArgs()
+    {
+        $ns_args = array(
+                         'preferred_auth_policies' =>
+                           implode(' ', $this->preferred_auth_policies)
+                         );
+
+        if ($this->max_auth_age !== null) {
+            $ns_args['max_auth_age'] = strval($this->max_auth_age);
+        }
+
+        return $ns_args;
+    }
+
+    /**
+     * Instantiate a Request object from the arguments in a checkid_*
+     * OpenID message
+     */
+    function fromOpenIDRequest($request)
+    {
+        $obj = new Auth_OpenID_PAPE_Request();
+        $args = $request->message->getArgs(Auth_OpenID_PAPE_NS_URI);
+
+        if ($args === null || $args === array()) {
+            return null;
+        }
+
+        $obj->parseExtensionArgs($args);
+        return $obj;
+    }
+
+    /**
+     * Set the state of this request to be that expressed in these
+     * PAPE arguments
+     *
+     * @param args: The PAPE arguments without a namespace
+     */
+    function parseExtensionArgs($args)
+    {
+        // preferred_auth_policies is a space-separated list of policy
+        // URIs
+        $this->preferred_auth_policies = array();
+
+        $policies_str = Auth_OpenID::arrayGet($args, 'preferred_auth_policies');
+        if ($policies_str) {
+            foreach (explode(' ', $policies_str) as $uri) {
+                if (!in_array($uri, $this->preferred_auth_policies)) {
+                    $this->preferred_auth_policies[] = $uri;
+                }
+            }
+        }
+
+        // max_auth_age is base-10 integer number of seconds
+        $max_auth_age_str = Auth_OpenID::arrayGet($args, 'max_auth_age');
+        if ($max_auth_age_str) {
+            $this->max_auth_age = Auth_OpenID::intval($max_auth_age_str);
+        } else {
+            $this->max_auth_age = null;
+        }
+    }
+
+    /**
+     * Given a list of authentication policy URIs that a provider
+     * supports, this method returns the subsequence of those types
+     * that are preferred by the relying party.
+     *
+     * @param supported_types: A sequence of authentication policy
+     * type URIs that are supported by a provider
+     *
+     * @return array The sub-sequence of the supported types that are
+     * preferred by the relying party. This list will be ordered in
+     * the order that the types appear in the supported_types
+     * sequence, and may be empty if the provider does not prefer any
+     * of the supported authentication types.
+     */
+    function preferredTypes($supported_types)
+    {
+        $result = array();
+
+        foreach ($supported_types as $st) {
+            if (in_array($st, $this->preferred_auth_policies)) {
+                $result[] = $st;
+            }
+        }
+        return $result;
+    }
+}
+
+/**
+ * A Provider Authentication Policy response, sent from a provider to
+ * a relying party
+ */
+class Auth_OpenID_PAPE_Response extends Auth_OpenID_Extension {
+
+    var $ns_alias = 'pape';
+    var $ns_uri = Auth_OpenID_PAPE_NS_URI;
+
+    function Auth_OpenID_PAPE_Response($auth_policies=null, $auth_time=null,
+                                       $nist_auth_level=null)
+    {
+        if ($auth_policies) {
+            $this->auth_policies = $auth_policies;
+        } else {
+            $this->auth_policies = array();
+        }
+
+        $this->auth_time = $auth_time;
+        $this->nist_auth_level = $nist_auth_level;
+    }
+
+    /**
+     * Add a authentication policy to this response
+     *
+     * This method is intended to be used by the provider to add a
+     * policy that the provider conformed to when authenticating the
+     * user.
+     *
+     * @param policy_uri: The identifier for the preferred type of
+     * authentication.
+     */
+    function addPolicyURI($policy_uri)
+    {
+        if (!in_array($policy_uri, $this->auth_policies)) {
+            $this->auth_policies[] = $policy_uri;
+        }
+    }
+
+    /**
+     * Create an Auth_OpenID_PAPE_Response object from a successful
+     * OpenID library response.
+     *
+     * @param success_response $success_response A SuccessResponse
+     * from Auth_OpenID_Consumer::complete()
+     *
+     * @returns: A provider authentication policy response from the
+     * data that was supplied with the id_res response.
+     */
+    function fromSuccessResponse($success_response)
+    {
+        $obj = new Auth_OpenID_PAPE_Response();
+
+        // PAPE requires that the args be signed.
+        $args = $success_response->getSignedNS(Auth_OpenID_PAPE_NS_URI);
+
+        if ($args === null || $args === array()) {
+            return null;
+        }
+
+        $result = $obj->parseExtensionArgs($args);
+
+        if ($result === false) {
+            return null;
+        } else {
+            return $obj;
+        }
+    }
+
+    /**
+     * Parse the provider authentication policy arguments into the
+     *  internal state of this object
+     *
+     * @param args: unqualified provider authentication policy
+     * arguments
+     *
+     * @param strict: Whether to return false when bad data is
+     * encountered
+     *
+     * @return null The data is parsed into the internal fields of
+     * this object.
+    */
+    function parseExtensionArgs($args, $strict=false)
+    {
+        $policies_str = Auth_OpenID::arrayGet($args, 'auth_policies');
+        if ($policies_str && $policies_str != "none") {
+            $this->auth_policies = explode(" ", $policies_str);
+        }
+
+        $nist_level_str = Auth_OpenID::arrayGet($args, 'nist_auth_level');
+        if ($nist_level_str !== null) {
+            $nist_level = Auth_OpenID::intval($nist_level_str);
+
+            if ($nist_level === false) {
+                if ($strict) {
+                    return false;
+                } else {
+                    $nist_level = null;
+                }
+            }
+
+            if (0 <= $nist_level && $nist_level < 5) {
+                $this->nist_auth_level = $nist_level;
+            } else if ($strict) {
+                return false;
+            }
+        }
+
+        $auth_time = Auth_OpenID::arrayGet($args, 'auth_time');
+        if ($auth_time !== null) {
+            if (ereg(PAPE_TIME_VALIDATOR, $auth_time)) {
+                $this->auth_time = $auth_time;
+            } else if ($strict) {
+                return false;
+            }
+        }
+    }
+
+    function getExtensionArgs()
+    {
+        $ns_args = array();
+        if (count($this->auth_policies) > 0) {
+            $ns_args['auth_policies'] = implode(' ', $this->auth_policies);
+        } else {
+            $ns_args['auth_policies'] = 'none';
+        }
+
+        if ($this->nist_auth_level !== null) {
+            if (!in_array($this->nist_auth_level, range(0, 4), true)) {
+                return false;
+            }
+            $ns_args['nist_auth_level'] = strval($this->nist_auth_level);
+        }
+
+        if ($this->auth_time !== null) {
+            if (!ereg(PAPE_TIME_VALIDATOR, $this->auth_time)) {
+                return false;
+            }
+
+            $ns_args['auth_time'] = $this->auth_time;
+        }
+
+        return $ns_args;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/Parse.php b/inc/lib/Auth/OpenID/Parse.php
new file mode 100644 (file)
index 0000000..546f34f
--- /dev/null
@@ -0,0 +1,352 @@
+<?php
+
+/**
+ * This module implements a VERY limited parser that finds <link> tags
+ * in the head of HTML or XHTML documents and parses out their
+ * attributes according to the OpenID spec. It is a liberal parser,
+ * but it requires these things from the data in order to work:
+ *
+ * - There must be an open <html> tag
+ *
+ * - There must be an open <head> tag inside of the <html> tag
+ *
+ * - Only <link>s that are found inside of the <head> tag are parsed
+ *   (this is by design)
+ *
+ * - The parser follows the OpenID specification in resolving the
+ *   attributes of the link tags. This means that the attributes DO
+ *   NOT get resolved as they would by an XML or HTML parser. In
+ *   particular, only certain entities get replaced, and href
+ *   attributes do not get resolved relative to a base URL.
+ *
+ * From http://openid.net/specs.bml:
+ *
+ * - The openid.server URL MUST be an absolute URL. OpenID consumers
+ *   MUST NOT attempt to resolve relative URLs.
+ *
+ * - The openid.server URL MUST NOT include entities other than &amp;,
+ *   &lt;, &gt;, and &quot;.
+ *
+ * The parser ignores SGML comments and <![CDATA[blocks]]>. Both kinds
+ * of quoting are allowed for attributes.
+ *
+ * The parser deals with invalid markup in these ways:
+ *
+ * - Tag names are not case-sensitive
+ *
+ * - The <html> tag is accepted even when it is not at the top level
+ *
+ * - The <head> tag is accepted even when it is not a direct child of
+ *   the <html> tag, but a <html> tag must be an ancestor of the
+ *   <head> tag
+ *
+ * - <link> tags are accepted even when they are not direct children
+ *   of the <head> tag, but a <head> tag must be an ancestor of the
+ *   <link> tag
+ *
+ * - If there is no closing tag for an open <html> or <head> tag, the
+ *   remainder of the document is viewed as being inside of the
+ *   tag. If there is no closing tag for a <link> tag, the link tag is
+ *   treated as a short tag. Exceptions to this rule are that <html>
+ *   closes <html> and <body> or <head> closes <head>
+ *
+ * - Attributes of the <link> tag are not required to be quoted.
+ *
+ * - In the case of duplicated attribute names, the attribute coming
+ *   last in the tag will be the value returned.
+ *
+ * - Any text that does not parse as an attribute within a link tag
+ *   will be ignored. (e.g. <link pumpkin rel='openid.server' /> will
+ *   ignore pumpkin)
+ *
+ * - If there are more than one <html> or <head> tag, the parser only
+ *   looks inside of the first one.
+ *
+ * - The contents of <script> tags are ignored entirely, except
+ *   unclosed <script> tags. Unclosed <script> tags are ignored.
+ *
+ * - Any other invalid markup is ignored, including unclosed SGML
+ *   comments and unclosed <![CDATA[blocks.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @access private
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Require Auth_OpenID::arrayGet().
+ */
+require_once "Auth/OpenID.php";
+
+class Auth_OpenID_Parse {
+
+    /**
+     * Specify some flags for use with regex matching.
+     */
+    var $_re_flags = "si";
+
+    /**
+     * Stuff to remove before we start looking for tags
+     */
+    var $_removed_re =
+           "<!--.*?-->|<!\[CDATA\[.*?\]\]>|<script\b(?!:)[^>]*>.*?<\/script>";
+
+    /**
+     * Starts with the tag name at a word boundary, where the tag name
+     * is not a namespace
+     */
+    var $_tag_expr = "<%s\b(?!:)([^>]*?)(?:\/>|>(.*?)(?:<\/?%s\s*>|\Z))";
+
+    var $_attr_find = '\b(\w+)=("[^"]*"|\'[^\']*\'|[^\'"\s\/<>]+)';
+
+    var $_open_tag_expr = "<%s\b";
+    var $_close_tag_expr = "<((\/%s\b)|(%s[^>\/]*\/))>";
+
+    function Auth_OpenID_Parse()
+    {
+        $this->_link_find = sprintf("/<link\b(?!:)([^>]*)(?!<)>/%s",
+                                    $this->_re_flags);
+
+        $this->_entity_replacements = array(
+                                            'amp' => '&',
+                                            'lt' => '<',
+                                            'gt' => '>',
+                                            'quot' => '"'
+                                            );
+
+        $this->_attr_find = sprintf("/%s/%s",
+                                    $this->_attr_find,
+                                    $this->_re_flags);
+
+        $this->_removed_re = sprintf("/%s/%s",
+                                     $this->_removed_re,
+                                     $this->_re_flags);
+
+        $this->_ent_replace =
+            sprintf("&(%s);", implode("|",
+                                      $this->_entity_replacements));
+    }
+
+    /**
+     * Returns a regular expression that will match a given tag in an
+     * SGML string.
+     */
+    function tagMatcher($tag_name, $close_tags = null)
+    {
+        $expr = $this->_tag_expr;
+
+        if ($close_tags) {
+            $options = implode("|", array_merge(array($tag_name), $close_tags));
+            $closer = sprintf("(?:%s)", $options);
+        } else {
+            $closer = $tag_name;
+        }
+
+        $expr = sprintf($expr, $tag_name, $closer);
+        return sprintf("/%s/%s", $expr, $this->_re_flags);
+    }
+
+    function openTag($tag_name)
+    {
+        $expr = sprintf($this->_open_tag_expr, $tag_name);
+        return sprintf("/%s/%s", $expr, $this->_re_flags);
+    }
+
+    function closeTag($tag_name)
+    {
+        $expr = sprintf($this->_close_tag_expr, $tag_name, $tag_name);
+        return sprintf("/%s/%s", $expr, $this->_re_flags);
+    }
+
+    function htmlBegin($s)
+    {
+        $matches = array();
+        $result = preg_match($this->openTag('html'), $s,
+                             $matches, PREG_OFFSET_CAPTURE);
+        if ($result === false || !$matches) {
+            return false;
+        }
+        // Return the offset of the first match.
+        return $matches[0][1];
+    }
+
+    function htmlEnd($s)
+    {
+        $matches = array();
+        $result = preg_match($this->closeTag('html'), $s,
+                             $matches, PREG_OFFSET_CAPTURE);
+        if ($result === false || !$matches) {
+            return false;
+        }
+        // Return the offset of the first match.
+        return $matches[count($matches) - 1][1];
+    }
+
+    function headFind()
+    {
+        return $this->tagMatcher('head', array('body', 'html'));
+    }
+
+    function replaceEntities($str)
+    {
+        foreach ($this->_entity_replacements as $old => $new) {
+            $str = preg_replace(sprintf("/&%s;/", $old), $new, $str);
+        }
+        return $str;
+    }
+
+    function removeQuotes($str)
+    {
+        $matches = array();
+        $double = '/^"(.*)"$/';
+        $single = "/^\'(.*)\'$/";
+
+        if (preg_match($double, $str, $matches)) {
+            return $matches[1];
+        } else if (preg_match($single, $str, $matches)) {
+            return $matches[1];
+        } else {
+            return $str;
+        }
+    }
+
+    /**
+     * Find all link tags in a string representing a HTML document and
+     * return a list of their attributes.
+     *
+     * @param string $html The text to parse
+     * @return array $list An array of arrays of attributes, one for each
+     * link tag
+     */
+    function parseLinkAttrs($html)
+    {
+        $stripped = preg_replace($this->_removed_re,
+                                 "",
+                                 $html);
+
+        $html_begin = $this->htmlBegin($stripped);
+        $html_end = $this->htmlEnd($stripped);
+
+        if ($html_begin === false) {
+            return array();
+        }
+
+        if ($html_end === false) {
+            $html_end = strlen($stripped);
+        }
+
+        $stripped = substr($stripped, $html_begin,
+                           $html_end - $html_begin);
+
+        // Try to find the <HEAD> tag.
+        $head_re = $this->headFind();
+        $head_matches = array();
+        if (!preg_match($head_re, $stripped, $head_matches)) {
+            return array();
+        }
+
+        $link_data = array();
+        $link_matches = array();
+
+        if (!preg_match_all($this->_link_find, $head_matches[0],
+                            $link_matches)) {
+            return array();
+        }
+
+        foreach ($link_matches[0] as $link) {
+            $attr_matches = array();
+            preg_match_all($this->_attr_find, $link, $attr_matches);
+            $link_attrs = array();
+            foreach ($attr_matches[0] as $index => $full_match) {
+                $name = $attr_matches[1][$index];
+                $value = $this->replaceEntities(
+                              $this->removeQuotes($attr_matches[2][$index]));
+
+                $link_attrs[strtolower($name)] = $value;
+            }
+            $link_data[] = $link_attrs;
+        }
+
+        return $link_data;
+    }
+
+    function relMatches($rel_attr, $target_rel)
+    {
+        // Does this target_rel appear in the rel_str?
+        // XXX: TESTME
+        $rels = preg_split("/\s+/", trim($rel_attr));
+        foreach ($rels as $rel) {
+            $rel = strtolower($rel);
+            if ($rel == $target_rel) {
+                return 1;
+            }
+        }
+
+        return 0;
+    }
+
+    function linkHasRel($link_attrs, $target_rel)
+    {
+        // Does this link have target_rel as a relationship?
+        // XXX: TESTME
+        $rel_attr = Auth_OpeniD::arrayGet($link_attrs, 'rel', null);
+        return ($rel_attr && $this->relMatches($rel_attr,
+                                               $target_rel));
+    }
+
+    function findLinksRel($link_attrs_list, $target_rel)
+    {
+        // Filter the list of link attributes on whether it has
+        // target_rel as a relationship.
+        // XXX: TESTME
+        $result = array();
+        foreach ($link_attrs_list as $attr) {
+            if ($this->linkHasRel($attr, $target_rel)) {
+                $result[] = $attr;
+            }
+        }
+
+        return $result;
+    }
+
+    function findFirstHref($link_attrs_list, $target_rel)
+    {
+        // Return the value of the href attribute for the first link
+        // tag in the list that has target_rel as a relationship.
+        // XXX: TESTME
+        $matches = $this->findLinksRel($link_attrs_list,
+                                       $target_rel);
+        if (!$matches) {
+            return null;
+        }
+        $first = $matches[0];
+        return Auth_OpenID::arrayGet($first, 'href', null);
+    }
+}
+
+function Auth_OpenID_legacy_discover($html_text, $server_rel,
+                                     $delegate_rel)
+{
+    $p = new Auth_OpenID_Parse();
+
+    $link_attrs = $p->parseLinkAttrs($html_text);
+
+    $server_url = $p->findFirstHref($link_attrs,
+                                    $server_rel);
+
+    if ($server_url === null) {
+        return false;
+    } else {
+        $delegate_url = $p->findFirstHref($link_attrs,
+                                          $delegate_rel);
+        return array($delegate_url, $server_url);
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/PostgreSQLStore.php b/inc/lib/Auth/OpenID/PostgreSQLStore.php
new file mode 100644 (file)
index 0000000..69d95e7
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * A PostgreSQL store.
+ *
+ * @package OpenID
+ */
+
+/**
+ * Require the base class file.
+ */
+require_once "Auth/OpenID/SQLStore.php";
+
+/**
+ * An SQL store that uses PostgreSQL as its backend.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_PostgreSQLStore extends Auth_OpenID_SQLStore {
+    /**
+     * @access private
+     */
+    function setSQL()
+    {
+        $this->sql['nonce_table'] =
+            "CREATE TABLE %s (server_url VARCHAR(2047) NOT NULL, ".
+                             "timestamp INTEGER NOT NULL, ".
+                             "salt CHAR(40) NOT NULL, ".
+                "UNIQUE (server_url, timestamp, salt))";
+
+        $this->sql['assoc_table'] =
+            "CREATE TABLE %s (server_url VARCHAR(2047) NOT NULL, ". 
+                             "handle VARCHAR(255) NOT NULL, ".
+                             "secret BYTEA NOT NULL, ".
+                             "issued INTEGER NOT NULL, ".
+                             "lifetime INTEGER NOT NULL, ".
+                             "assoc_type VARCHAR(64) NOT NULL, ".
+            "PRIMARY KEY (server_url, handle), ".
+            "CONSTRAINT secret_length_constraint CHECK ".
+            "(LENGTH(secret) <= 128))";
+
+        $this->sql['set_assoc'] =
+            array(
+                  'insert_assoc' => "INSERT INTO %s (server_url, handle, ".
+                  "secret, issued, lifetime, assoc_type) VALUES ".
+                  "(?, ?, '!', ?, ?, ?)",
+                  'update_assoc' => "UPDATE %s SET secret = '!', issued = ?, ".
+                  "lifetime = ?, assoc_type = ? WHERE server_url = ? AND ".
+                  "handle = ?"
+                  );
+
+        $this->sql['get_assocs'] =
+            "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ".
+            "WHERE server_url = ?";
+
+        $this->sql['get_assoc'] =
+            "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ".
+            "WHERE server_url = ? AND handle = ?";
+
+        $this->sql['remove_assoc'] =
+            "DELETE FROM %s WHERE server_url = ? AND handle = ?";
+
+        $this->sql['add_nonce'] =
+                  "INSERT INTO %s (server_url, timestamp, salt) VALUES ".
+                  "(?, ?, ?)"
+                  ;
+
+        $this->sql['clean_nonce'] =
+            "DELETE FROM %s WHERE timestamp < ?";
+
+        $this->sql['clean_assoc'] =
+            "DELETE FROM %s WHERE issued + lifetime < ?";
+    }
+
+    /**
+     * @access private
+     */
+    function _set_assoc($server_url, $handle, $secret, $issued, $lifetime,
+                        $assoc_type)
+    {
+        $result = $this->_get_assoc($server_url, $handle);
+        if ($result) {
+            // Update the table since this associations already exists.
+            $this->connection->query($this->sql['set_assoc']['update_assoc'],
+                                     array($secret, $issued, $lifetime,
+                                           $assoc_type, $server_url, $handle));
+        } else {
+            // Insert a new record because this association wasn't
+            // found.
+            $this->connection->query($this->sql['set_assoc']['insert_assoc'],
+                                     array($server_url, $handle, $secret,
+                                           $issued, $lifetime, $assoc_type));
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function blobEncode($blob)
+    {
+        return $this->_octify($blob);
+    }
+
+    /**
+     * @access private
+     */
+    function blobDecode($blob)
+    {
+        return $this->_unoctify($blob);
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/SQLStore.php b/inc/lib/Auth/OpenID/SQLStore.php
new file mode 100644 (file)
index 0000000..da93c6a
--- /dev/null
@@ -0,0 +1,569 @@
+<?php
+
+/**
+ * SQL-backed OpenID stores.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Require the PEAR DB module because we'll need it for the SQL-based
+ * stores implemented here.  We silence any errors from the inclusion
+ * because it might not be present, and a user of the SQL stores may
+ * supply an Auth_OpenID_DatabaseConnection instance that implements
+ * its own storage.
+ */
+global $__Auth_OpenID_PEAR_AVAILABLE;
+$__Auth_OpenID_PEAR_AVAILABLE = @include_once 'DB.php';
+
+/**
+ * @access private
+ */
+require_once 'Auth/OpenID/Interface.php';
+require_once 'Auth/OpenID/Nonce.php';
+
+/**
+ * @access private
+ */
+require_once 'Auth/OpenID.php';
+
+/**
+ * @access private
+ */
+require_once 'Auth/OpenID/Nonce.php';
+
+/**
+ * This is the parent class for the SQL stores, which contains the
+ * logic common to all of the SQL stores.
+ *
+ * The table names used are determined by the class variables
+ * associations_table_name and nonces_table_name.  To change the name
+ * of the tables used, pass new table names into the constructor.
+ *
+ * To create the tables with the proper schema, see the createTables
+ * method.
+ *
+ * This class shouldn't be used directly.  Use one of its subclasses
+ * instead, as those contain the code necessary to use a specific
+ * database.  If you're an OpenID integrator and you'd like to create
+ * an SQL-driven store that wraps an application's database
+ * abstraction, be sure to create a subclass of
+ * {@link Auth_OpenID_DatabaseConnection} that calls the application's
+ * database abstraction calls.  Then, pass an instance of your new
+ * database connection class to your SQLStore subclass constructor.
+ *
+ * All methods other than the constructor and createTables should be
+ * considered implementation details.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SQLStore extends Auth_OpenID_OpenIDStore {
+
+    /**
+     * This creates a new SQLStore instance.  It requires an
+     * established database connection be given to it, and it allows
+     * overriding the default table names.
+     *
+     * @param connection $connection This must be an established
+     * connection to a database of the correct type for the SQLStore
+     * subclass you're using.  This must either be an PEAR DB
+     * connection handle or an instance of a subclass of
+     * Auth_OpenID_DatabaseConnection.
+     *
+     * @param associations_table: This is an optional parameter to
+     * specify the name of the table used for storing associations.
+     * The default value is 'oid_associations'.
+     *
+     * @param nonces_table: This is an optional parameter to specify
+     * the name of the table used for storing nonces.  The default
+     * value is 'oid_nonces'.
+     */
+    function Auth_OpenID_SQLStore($connection,
+                                  $associations_table = null,
+                                  $nonces_table = null)
+    {
+        global $__Auth_OpenID_PEAR_AVAILABLE;
+
+        $this->associations_table_name = "oid_associations";
+        $this->nonces_table_name = "oid_nonces";
+
+        // Check the connection object type to be sure it's a PEAR
+        // database connection.
+        if (!(is_object($connection) &&
+              (is_subclass_of($connection, 'db_common') ||
+               is_subclass_of($connection,
+                              'auth_openid_databaseconnection')))) {
+            trigger_error("Auth_OpenID_SQLStore expected PEAR connection " .
+                          "object (got ".get_class($connection).")",
+                          E_USER_ERROR);
+            return;
+        }
+
+        $this->connection = $connection;
+
+        // Be sure to set the fetch mode so the results are keyed on
+        // column name instead of column index.  This is a PEAR
+        // constant, so only try to use it if PEAR is present.  Note
+        // that Auth_Openid_Databaseconnection instances need not
+        // implement ::setFetchMode for this reason.
+        if ($__Auth_OpenID_PEAR_AVAILABLE) {
+            $this->connection->setFetchMode(DB_FETCHMODE_ASSOC);
+        }
+
+        if ($associations_table) {
+            $this->associations_table_name = $associations_table;
+        }
+
+        if ($nonces_table) {
+            $this->nonces_table_name = $nonces_table;
+        }
+
+        $this->max_nonce_age = 6 * 60 * 60;
+
+        // Be sure to run the database queries with auto-commit mode
+        // turned OFF, because we want every function to run in a
+        // transaction, implicitly.  As a rule, methods named with a
+        // leading underscore will NOT control transaction behavior.
+        // Callers of these methods will worry about transactions.
+        $this->connection->autoCommit(false);
+
+        // Create an empty SQL strings array.
+        $this->sql = array();
+
+        // Call this method (which should be overridden by subclasses)
+        // to populate the $this->sql array with SQL strings.
+        $this->setSQL();
+
+        // Verify that all required SQL statements have been set, and
+        // raise an error if any expected SQL strings were either
+        // absent or empty.
+        list($missing, $empty) = $this->_verifySQL();
+
+        if ($missing) {
+            trigger_error("Expected keys in SQL query list: " .
+                          implode(", ", $missing),
+                          E_USER_ERROR);
+            return;
+        }
+
+        if ($empty) {
+            trigger_error("SQL list keys have no SQL strings: " .
+                          implode(", ", $empty),
+                          E_USER_ERROR);
+            return;
+        }
+
+        // Add table names to queries.
+        $this->_fixSQL();
+    }
+
+    function tableExists($table_name)
+    {
+        return !$this->isError(
+                      $this->connection->query(
+                          sprintf("SELECT * FROM %s LIMIT 0",
+                                  $table_name)));
+    }
+
+    /**
+     * Returns true if $value constitutes a database error; returns
+     * false otherwise.
+     */
+    function isError($value)
+    {
+        return PEAR::isError($value);
+    }
+
+    /**
+     * Converts a query result to a boolean.  If the result is a
+     * database error according to $this->isError(), this returns
+     * false; otherwise, this returns true.
+     */
+    function resultToBool($obj)
+    {
+        if ($this->isError($obj)) {
+            return false;
+        } else {
+            return true;
+        }
+    }
+
+    /**
+     * This method should be overridden by subclasses.  This method is
+     * called by the constructor to set values in $this->sql, which is
+     * an array keyed on sql name.
+     */
+    function setSQL()
+    {
+    }
+
+    /**
+     * Resets the store by removing all records from the store's
+     * tables.
+     */
+    function reset()
+    {
+        $this->connection->query(sprintf("DELETE FROM %s",
+                                         $this->associations_table_name));
+
+        $this->connection->query(sprintf("DELETE FROM %s",
+                                         $this->nonces_table_name));
+    }
+
+    /**
+     * @access private
+     */
+    function _verifySQL()
+    {
+        $missing = array();
+        $empty = array();
+
+        $required_sql_keys = array(
+                                   'nonce_table',
+                                   'assoc_table',
+                                   'set_assoc',
+                                   'get_assoc',
+                                   'get_assocs',
+                                   'remove_assoc'
+                                   );
+
+        foreach ($required_sql_keys as $key) {
+            if (!array_key_exists($key, $this->sql)) {
+                $missing[] = $key;
+            } else if (!$this->sql[$key]) {
+                $empty[] = $key;
+            }
+        }
+
+        return array($missing, $empty);
+    }
+
+    /**
+     * @access private
+     */
+    function _fixSQL()
+    {
+        $replacements = array(
+                              array(
+                                    'value' => $this->nonces_table_name,
+                                    'keys' => array('nonce_table',
+                                                    'add_nonce',
+                                                    'clean_nonce')
+                                    ),
+                              array(
+                                    'value' => $this->associations_table_name,
+                                    'keys' => array('assoc_table',
+                                                    'set_assoc',
+                                                    'get_assoc',
+                                                    'get_assocs',
+                                                    'remove_assoc',
+                                                    'clean_assoc')
+                                    )
+                              );
+
+        foreach ($replacements as $item) {
+            $value = $item['value'];
+            $keys = $item['keys'];
+
+            foreach ($keys as $k) {
+                if (is_array($this->sql[$k])) {
+                    foreach ($this->sql[$k] as $part_key => $part_value) {
+                        $this->sql[$k][$part_key] = sprintf($part_value,
+                                                            $value);
+                    }
+                } else {
+                    $this->sql[$k] = sprintf($this->sql[$k], $value);
+                }
+            }
+        }
+    }
+
+    function blobDecode($blob)
+    {
+        return $blob;
+    }
+
+    function blobEncode($str)
+    {
+        return $str;
+    }
+
+    function createTables()
+    {
+        $this->connection->autoCommit(true);
+        $n = $this->create_nonce_table();
+        $a = $this->create_assoc_table();
+        $this->connection->autoCommit(false);
+
+        if ($n && $a) {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    function create_nonce_table()
+    {
+        if (!$this->tableExists($this->nonces_table_name)) {
+            $r = $this->connection->query($this->sql['nonce_table']);
+            return $this->resultToBool($r);
+        }
+        return true;
+    }
+
+    function create_assoc_table()
+    {
+        if (!$this->tableExists($this->associations_table_name)) {
+            $r = $this->connection->query($this->sql['assoc_table']);
+            return $this->resultToBool($r);
+        }
+        return true;
+    }
+
+    /**
+     * @access private
+     */
+    function _set_assoc($server_url, $handle, $secret, $issued,
+                        $lifetime, $assoc_type)
+    {
+        return $this->connection->query($this->sql['set_assoc'],
+                                        array(
+                                              $server_url,
+                                              $handle,
+                                              $secret,
+                                              $issued,
+                                              $lifetime,
+                                              $assoc_type));
+    }
+
+    function storeAssociation($server_url, $association)
+    {
+        if ($this->resultToBool($this->_set_assoc(
+                                            $server_url,
+                                            $association->handle,
+                                            $this->blobEncode(
+                                                  $association->secret),
+                                            $association->issued,
+                                            $association->lifetime,
+                                            $association->assoc_type
+                                            ))) {
+            $this->connection->commit();
+        } else {
+            $this->connection->rollback();
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _get_assoc($server_url, $handle)
+    {
+        $result = $this->connection->getRow($this->sql['get_assoc'],
+                                            array($server_url, $handle));
+        if ($this->isError($result)) {
+            return null;
+        } else {
+            return $result;
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _get_assocs($server_url)
+    {
+        $result = $this->connection->getAll($this->sql['get_assocs'],
+                                            array($server_url));
+
+        if ($this->isError($result)) {
+            return array();
+        } else {
+            return $result;
+        }
+    }
+
+    function removeAssociation($server_url, $handle)
+    {
+        if ($this->_get_assoc($server_url, $handle) == null) {
+            return false;
+        }
+
+        if ($this->resultToBool($this->connection->query(
+                              $this->sql['remove_assoc'],
+                              array($server_url, $handle)))) {
+            $this->connection->commit();
+        } else {
+            $this->connection->rollback();
+        }
+
+        return true;
+    }
+
+    function getAssociation($server_url, $handle = null)
+    {
+        if ($handle !== null) {
+            $assoc = $this->_get_assoc($server_url, $handle);
+
+            $assocs = array();
+            if ($assoc) {
+                $assocs[] = $assoc;
+            }
+        } else {
+            $assocs = $this->_get_assocs($server_url);
+        }
+
+        if (!$assocs || (count($assocs) == 0)) {
+            return null;
+        } else {
+            $associations = array();
+
+            foreach ($assocs as $assoc_row) {
+                $assoc = new Auth_OpenID_Association($assoc_row['handle'],
+                                                     $assoc_row['secret'],
+                                                     $assoc_row['issued'],
+                                                     $assoc_row['lifetime'],
+                                                     $assoc_row['assoc_type']);
+
+                $assoc->secret = $this->blobDecode($assoc->secret);
+
+                if ($assoc->getExpiresIn() == 0) {
+                    $this->removeAssociation($server_url, $assoc->handle);
+                } else {
+                    $associations[] = array($assoc->issued, $assoc);
+                }
+            }
+
+            if ($associations) {
+                $issued = array();
+                $assocs = array();
+                foreach ($associations as $key => $assoc) {
+                    $issued[$key] = $assoc[0];
+                    $assocs[$key] = $assoc[1];
+                }
+
+                array_multisort($issued, SORT_DESC, $assocs, SORT_DESC,
+                                $associations);
+
+                // return the most recently issued one.
+                list($issued, $assoc) = $associations[0];
+                return $assoc;
+            } else {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function _add_nonce($server_url, $timestamp, $salt)
+    {
+        $sql = $this->sql['add_nonce'];
+        $result = $this->connection->query($sql, array($server_url,
+                                                       $timestamp,
+                                                       $salt));
+        if ($this->isError($result)) {
+            $this->connection->rollback();
+        } else {
+            $this->connection->commit();
+        }
+        return $this->resultToBool($result);
+    }
+
+    function useNonce($server_url, $timestamp, $salt)
+    {
+        global $Auth_OpenID_SKEW;
+
+        if ( abs($timestamp - time()) > $Auth_OpenID_SKEW ) {
+            return False;
+        }
+
+        return $this->_add_nonce($server_url, $timestamp, $salt);
+    }
+
+    /**
+     * "Octifies" a binary string by returning a string with escaped
+     * octal bytes.  This is used for preparing binary data for
+     * PostgreSQL BYTEA fields.
+     *
+     * @access private
+     */
+    function _octify($str)
+    {
+        $result = "";
+        for ($i = 0; $i < Auth_OpenID::bytes($str); $i++) {
+            $ch = substr($str, $i, 1);
+            if ($ch == "\\") {
+                $result .= "\\\\\\\\";
+            } else if (ord($ch) == 0) {
+                $result .= "\\\\000";
+            } else {
+                $result .= "\\" . strval(decoct(ord($ch)));
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * "Unoctifies" octal-escaped data from PostgreSQL and returns the
+     * resulting ASCII (possibly binary) string.
+     *
+     * @access private
+     */
+    function _unoctify($str)
+    {
+        $result = "";
+        $i = 0;
+        while ($i < strlen($str)) {
+            $char = $str[$i];
+            if ($char == "\\") {
+                // Look to see if the next char is a backslash and
+                // append it.
+                if ($str[$i + 1] != "\\") {
+                    $octal_digits = substr($str, $i + 1, 3);
+                    $dec = octdec($octal_digits);
+                    $char = chr($dec);
+                    $i += 4;
+                } else {
+                    $char = "\\";
+                    $i += 2;
+                }
+            } else {
+                $i += 1;
+            }
+
+            $result .= $char;
+        }
+
+        return $result;
+    }
+
+    function cleanupNonces()
+    {
+        global $Auth_OpenID_SKEW;
+        $v = time() - $Auth_OpenID_SKEW;
+
+        $this->connection->query($this->sql['clean_nonce'], array($v));
+        $num = $this->connection->affectedRows();
+        $this->connection->commit();
+        return $num;
+    }
+
+    function cleanupAssociations()
+    {
+        $this->connection->query($this->sql['clean_assoc'],
+                                 array(time()));
+        $num = $this->connection->affectedRows();
+        $this->connection->commit();
+        return $num;
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/SQLiteStore.php b/inc/lib/Auth/OpenID/SQLiteStore.php
new file mode 100644 (file)
index 0000000..ec2bf58
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * An SQLite store.
+ *
+ * @package OpenID
+ */
+
+/**
+ * Require the base class file.
+ */
+require_once "Auth/OpenID/SQLStore.php";
+
+/**
+ * An SQL store that uses SQLite as its backend.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SQLiteStore extends Auth_OpenID_SQLStore {
+    function setSQL()
+    {
+        $this->sql['nonce_table'] =
+            "CREATE TABLE %s (server_url VARCHAR(2047), timestamp INTEGER, ".
+            "salt CHAR(40), UNIQUE (server_url, timestamp, salt))";
+
+        $this->sql['assoc_table'] =
+            "CREATE TABLE %s (server_url VARCHAR(2047), handle VARCHAR(255), ".
+            "secret BLOB(128), issued INTEGER, lifetime INTEGER, ".
+            "assoc_type VARCHAR(64), PRIMARY KEY (server_url, handle))";
+
+        $this->sql['set_assoc'] =
+            "INSERT OR REPLACE INTO %s VALUES (?, ?, ?, ?, ?, ?)";
+
+        $this->sql['get_assocs'] =
+            "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ".
+            "WHERE server_url = ?";
+
+        $this->sql['get_assoc'] =
+            "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ".
+            "WHERE server_url = ? AND handle = ?";
+
+        $this->sql['remove_assoc'] =
+            "DELETE FROM %s WHERE server_url = ? AND handle = ?";
+
+        $this->sql['add_nonce'] =
+            "INSERT INTO %s (server_url, timestamp, salt) VALUES (?, ?, ?)";
+
+        $this->sql['clean_nonce'] =
+            "DELETE FROM %s WHERE timestamp < ?";
+
+        $this->sql['clean_assoc'] =
+            "DELETE FROM %s WHERE issued + lifetime < ?";
+    }
+
+    /**
+     * @access private
+     */
+    function _add_nonce($server_url, $timestamp, $salt)
+    {
+        // PECL SQLite extensions 1.0.3 and older (1.0.3 is the
+        // current release at the time of this writing) have a broken
+        // sqlite_escape_string function that breaks when passed the
+        // empty string. Prefixing all strings with one character
+        // keeps them unique and avoids this bug. The nonce table is
+        // write-only, so we don't have to worry about updating other
+        // functions with this same bad hack.
+        return parent::_add_nonce('x' . $server_url, $timestamp, $salt);
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/SReg.php b/inc/lib/Auth/OpenID/SReg.php
new file mode 100644 (file)
index 0000000..6328076
--- /dev/null
@@ -0,0 +1,521 @@
+<?php
+
+/**
+ * Simple registration request and response parsing and object
+ * representation.
+ *
+ * This module contains objects representing simple registration
+ * requests and responses that can be used with both OpenID relying
+ * parties and OpenID providers.
+ *
+ * 1. The relying party creates a request object and adds it to the
+ * {@link Auth_OpenID_AuthRequest} object before making the
+ * checkid request to the OpenID provider:
+ *
+ *   $sreg_req = Auth_OpenID_SRegRequest::build(array('email'));
+ *   $auth_request->addExtension($sreg_req);
+ *
+ * 2. The OpenID provider extracts the simple registration request
+ * from the OpenID request using {@link
+ * Auth_OpenID_SRegRequest::fromOpenIDRequest}, gets the user's
+ * approval and data, creates an {@link Auth_OpenID_SRegResponse}
+ * object and adds it to the id_res response:
+ *
+ *   $sreg_req = Auth_OpenID_SRegRequest::fromOpenIDRequest(
+ *                                  $checkid_request);
+ *   // [ get the user's approval and data, informing the user that
+ *   //   the fields in sreg_response were requested ]
+ *   $sreg_resp = Auth_OpenID_SRegResponse::extractResponse(
+ *                                  $sreg_req, $user_data);
+ *   $sreg_resp->toMessage($openid_response->fields);
+ *
+ * 3. The relying party uses {@link
+ * Auth_OpenID_SRegResponse::fromSuccessResponse} to extract the data
+ * from the OpenID response:
+ *
+ *   $sreg_resp = Auth_OpenID_SRegResponse::fromSuccessResponse(
+ *                                  $success_response);
+ *
+ * @package OpenID
+ */
+
+/**
+ * Import message and extension internals.
+ */
+require_once 'Auth/OpenID/Message.php';
+require_once 'Auth/OpenID/Extension.php';
+
+// The data fields that are listed in the sreg spec
+global $Auth_OpenID_sreg_data_fields;
+$Auth_OpenID_sreg_data_fields = array(
+                                      'fullname' => 'Full Name',
+                                      'nickname' => 'Nickname',
+                                      'dob' => 'Date of Birth',
+                                      'email' => 'E-mail Address',
+                                      'gender' => 'Gender',
+                                      'postcode' => 'Postal Code',
+                                      'country' => 'Country',
+                                      'language' => 'Language',
+                                      'timezone' => 'Time Zone');
+
+/**
+ * Check to see that the given value is a valid simple registration
+ * data field name.  Return true if so, false if not.
+ */
+function Auth_OpenID_checkFieldName($field_name)
+{
+    global $Auth_OpenID_sreg_data_fields;
+
+    if (!in_array($field_name, array_keys($Auth_OpenID_sreg_data_fields))) {
+        return false;
+    }
+    return true;
+}
+
+// URI used in the wild for Yadis documents advertising simple
+// registration support
+define('Auth_OpenID_SREG_NS_URI_1_0', 'http://openid.net/sreg/1.0');
+
+// URI in the draft specification for simple registration 1.1
+// <http://openid.net/specs/openid-simple-registration-extension-1_1-01.html>
+define('Auth_OpenID_SREG_NS_URI_1_1', 'http://openid.net/extensions/sreg/1.1');
+
+// This attribute will always hold the preferred URI to use when
+// adding sreg support to an XRDS file or in an OpenID namespace
+// declaration.
+define('Auth_OpenID_SREG_NS_URI', Auth_OpenID_SREG_NS_URI_1_1);
+
+Auth_OpenID_registerNamespaceAlias(Auth_OpenID_SREG_NS_URI_1_1, 'sreg');
+
+/**
+ * Does the given endpoint advertise support for simple
+ * registration?
+ *
+ * $endpoint: The endpoint object as returned by OpenID discovery.
+ * returns whether an sreg type was advertised by the endpoint
+ */
+function Auth_OpenID_supportsSReg(&$endpoint)
+{
+    return ($endpoint->usesExtension(Auth_OpenID_SREG_NS_URI_1_1) ||
+            $endpoint->usesExtension(Auth_OpenID_SREG_NS_URI_1_0));
+}
+
+/**
+ * A base class for classes dealing with Simple Registration protocol
+ * messages.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SRegBase extends Auth_OpenID_Extension {
+    /**
+     * Extract the simple registration namespace URI from the given
+     * OpenID message. Handles OpenID 1 and 2, as well as both sreg
+     * namespace URIs found in the wild, as well as missing namespace
+     * definitions (for OpenID 1)
+     *
+     * $message: The OpenID message from which to parse simple
+     * registration fields. This may be a request or response message.
+     *
+     * Returns the sreg namespace URI for the supplied message. The
+     * message may be modified to define a simple registration
+     * namespace.
+     *
+     * @access private
+     */
+    function _getSRegNS(&$message)
+    {
+        $alias = null;
+        $found_ns_uri = null;
+
+        // See if there exists an alias for one of the two defined
+        // simple registration types.
+        foreach (array(Auth_OpenID_SREG_NS_URI_1_1,
+                       Auth_OpenID_SREG_NS_URI_1_0) as $sreg_ns_uri) {
+            $alias = $message->namespaces->getAlias($sreg_ns_uri);
+            if ($alias !== null) {
+                $found_ns_uri = $sreg_ns_uri;
+                break;
+            }
+        }
+
+        if ($alias === null) {
+            // There is no alias for either of the types, so try to
+            // add one. We default to using the modern value (1.1)
+            $found_ns_uri = Auth_OpenID_SREG_NS_URI_1_1;
+            if ($message->namespaces->addAlias(Auth_OpenID_SREG_NS_URI_1_1,
+                                               'sreg') === null) {
+                // An alias for the string 'sreg' already exists, but
+                // it's defined for something other than simple
+                // registration
+                return null;
+            }
+        }
+
+        return $found_ns_uri;
+    }
+}
+
+/**
+ * An object to hold the state of a simple registration request.
+ *
+ * required: A list of the required fields in this simple registration
+ * request
+ *
+ * optional: A list of the optional fields in this simple registration
+ * request
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SRegRequest extends Auth_OpenID_SRegBase {
+
+    var $ns_alias = 'sreg';
+
+    /**
+     * Initialize an empty simple registration request.
+     */
+    function build($required=null, $optional=null,
+                   $policy_url=null,
+                   $sreg_ns_uri=Auth_OpenID_SREG_NS_URI,
+                   $cls='Auth_OpenID_SRegRequest')
+    {
+        $obj = new $cls();
+
+        $obj->required = array();
+        $obj->optional = array();
+        $obj->policy_url = $policy_url;
+        $obj->ns_uri = $sreg_ns_uri;
+
+        if ($required) {
+            if (!$obj->requestFields($required, true, true)) {
+                return null;
+            }
+        }
+
+        if ($optional) {
+            if (!$obj->requestFields($optional, false, true)) {
+                return null;
+            }
+        }
+
+        return $obj;
+    }
+
+    /**
+     * Create a simple registration request that contains the fields
+     * that were requested in the OpenID request with the given
+     * arguments
+     *
+     * $request: The OpenID authentication request from which to
+     * extract an sreg request.
+     *
+     * $cls: name of class to use when creating sreg request object.
+     * Used for testing.
+     *
+     * Returns the newly created simple registration request
+     */
+    function fromOpenIDRequest($request, $cls='Auth_OpenID_SRegRequest')
+    {
+
+        $obj = call_user_func_array(array($cls, 'build'),
+                 array(null, null, null, Auth_OpenID_SREG_NS_URI, $cls));
+
+        // Since we're going to mess with namespace URI mapping, don't
+        // mutate the object that was passed in.
+        $m = $request->message;
+
+        $obj->ns_uri = $obj->_getSRegNS($m);
+        $args = $m->getArgs($obj->ns_uri);
+
+        if ($args === null || Auth_OpenID::isFailure($args)) {
+            return null;
+        }
+
+        $obj->parseExtensionArgs($args);
+
+        return $obj;
+    }
+
+    /**
+     * Parse the unqualified simple registration request parameters
+     * and add them to this object.
+     *
+     * This method is essentially the inverse of
+     * getExtensionArgs. This method restores the serialized simple
+     * registration request fields.
+     *
+     * If you are extracting arguments from a standard OpenID
+     * checkid_* request, you probably want to use fromOpenIDRequest,
+     * which will extract the sreg namespace and arguments from the
+     * OpenID request. This method is intended for cases where the
+     * OpenID server needs more control over how the arguments are
+     * parsed than that method provides.
+     *
+     * $args == $message->getArgs($ns_uri);
+     * $request->parseExtensionArgs($args);
+     *
+     * $args: The unqualified simple registration arguments
+     *
+     * strict: Whether requests with fields that are not defined in
+     * the simple registration specification should be tolerated (and
+     * ignored)
+     */
+    function parseExtensionArgs($args, $strict=false)
+    {
+        foreach (array('required', 'optional') as $list_name) {
+            $required = ($list_name == 'required');
+            $items = Auth_OpenID::arrayGet($args, $list_name);
+            if ($items) {
+                foreach (explode(',', $items) as $field_name) {
+                    if (!$this->requestField($field_name, $required, $strict)) {
+                        if ($strict) {
+                            return false;
+                        }
+                    }
+                }
+            }
+        }
+
+        $this->policy_url = Auth_OpenID::arrayGet($args, 'policy_url');
+
+        return true;
+    }
+
+    /**
+     * A list of all of the simple registration fields that were
+     * requested, whether they were required or optional.
+     */
+    function allRequestedFields()
+    {
+        return array_merge($this->required, $this->optional);
+    }
+
+    /**
+     * Have any simple registration fields been requested?
+     */
+    function wereFieldsRequested()
+    {
+        return count($this->allRequestedFields());
+    }
+
+    /**
+     * Was this field in the request?
+     */
+    function contains($field_name)
+    {
+        return (in_array($field_name, $this->required) ||
+                in_array($field_name, $this->optional));
+    }
+
+    /**
+     * Request the specified field from the OpenID user
+     *
+     * $field_name: the unqualified simple registration field name
+     *
+     * required: whether the given field should be presented to the
+     * user as being a required to successfully complete the request
+     *
+     * strict: whether to raise an exception when a field is added to
+     * a request more than once
+     */
+    function requestField($field_name,
+                          $required=false, $strict=false)
+    {
+        if (!Auth_OpenID_checkFieldName($field_name)) {
+            return false;
+        }
+
+        if ($strict) {
+            if ($this->contains($field_name)) {
+                return false;
+            }
+        } else {
+            if (in_array($field_name, $this->required)) {
+                return true;
+            }
+
+            if (in_array($field_name, $this->optional)) {
+                if ($required) {
+                    unset($this->optional[array_search($field_name,
+                                                       $this->optional)]);
+                } else {
+                    return true;
+                }
+            }
+        }
+
+        if ($required) {
+            $this->required[] = $field_name;
+        } else {
+            $this->optional[] = $field_name;
+        }
+
+        return true;
+    }
+
+    /**
+     * Add the given list of fields to the request
+     *
+     * field_names: The simple registration data fields to request
+     *
+     * required: Whether these values should be presented to the user
+     * as required
+     *
+     * strict: whether to raise an exception when a field is added to
+     * a request more than once
+     */
+    function requestFields($field_names, $required=false, $strict=false)
+    {
+        if (!is_array($field_names)) {
+            return false;
+        }
+
+        foreach ($field_names as $field_name) {
+            if (!$this->requestField($field_name, $required, $strict=$strict)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Get a dictionary of unqualified simple registration arguments
+     * representing this request.
+     *
+     * This method is essentially the inverse of
+     * C{L{parseExtensionArgs}}. This method serializes the simple
+     * registration request fields.
+     */
+    function getExtensionArgs()
+    {
+        $args = array();
+
+        if ($this->required) {
+            $args['required'] = implode(',', $this->required);
+        }
+
+        if ($this->optional) {
+            $args['optional'] = implode(',', $this->optional);
+        }
+
+        if ($this->policy_url) {
+            $args['policy_url'] = $this->policy_url;
+        }
+
+        return $args;
+    }
+}
+
+/**
+ * Represents the data returned in a simple registration response
+ * inside of an OpenID C{id_res} response. This object will be created
+ * by the OpenID server, added to the C{id_res} response object, and
+ * then extracted from the C{id_res} message by the Consumer.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SRegResponse extends Auth_OpenID_SRegBase {
+
+    var $ns_alias = 'sreg';
+
+    function Auth_OpenID_SRegResponse($data=null,
+                                      $sreg_ns_uri=Auth_OpenID_SREG_NS_URI)
+    {
+        if ($data === null) {
+            $this->data = array();
+        } else {
+            $this->data = $data;
+        }
+
+        $this->ns_uri = $sreg_ns_uri;
+    }
+
+    /**
+     * Take a C{L{SRegRequest}} and a dictionary of simple
+     * registration values and create a C{L{SRegResponse}} object
+     * containing that data.
+     *
+     * request: The simple registration request object
+     *
+     * data: The simple registration data for this response, as a
+     * dictionary from unqualified simple registration field name to
+     * string (unicode) value. For instance, the nickname should be
+     * stored under the key 'nickname'.
+     */
+    function extractResponse($request, $data)
+    {
+        $obj = new Auth_OpenID_SRegResponse();
+        $obj->ns_uri = $request->ns_uri;
+
+        foreach ($request->allRequestedFields() as $field) {
+            $value = Auth_OpenID::arrayGet($data, $field);
+            if ($value !== null) {
+                $obj->data[$field] = $value;
+            }
+        }
+
+        return $obj;
+    }
+
+    /**
+     * Create a C{L{SRegResponse}} object from a successful OpenID
+     * library response
+     * (C{L{openid.consumer.consumer.SuccessResponse}}) response
+     * message
+     *
+     * success_response: A SuccessResponse from consumer.complete()
+     *
+     * signed_only: Whether to process only data that was
+     * signed in the id_res message from the server.
+     *
+     * Returns a simple registration response containing the data that
+     * was supplied with the C{id_res} response.
+     */
+    function fromSuccessResponse(&$success_response, $signed_only=true)
+    {
+        global $Auth_OpenID_sreg_data_fields;
+
+        $obj = new Auth_OpenID_SRegResponse();
+        $obj->ns_uri = $obj->_getSRegNS($success_response->message);
+
+        if ($signed_only) {
+            $args = $success_response->getSignedNS($obj->ns_uri);
+        } else {
+            $args = $success_response->message->getArgs($obj->ns_uri);
+        }
+
+        if ($args === null || Auth_OpenID::isFailure($args)) {
+            return null;
+        }
+
+        foreach ($Auth_OpenID_sreg_data_fields as $field_name => $desc) {
+            if (in_array($field_name, array_keys($args))) {
+                $obj->data[$field_name] = $args[$field_name];
+            }
+        }
+
+        return $obj;
+    }
+
+    function getExtensionArgs()
+    {
+        return $this->data;
+    }
+
+    // Read-only dictionary interface
+    function get($field_name, $default=null)
+    {
+        if (!Auth_OpenID_checkFieldName($field_name)) {
+            return null;
+        }
+
+        return Auth_OpenID::arrayGet($this->data, $field_name, $default);
+    }
+
+    function contents()
+    {
+        return $this->data;
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/Server.php b/inc/lib/Auth/OpenID/Server.php
new file mode 100644 (file)
index 0000000..f1db4d8
--- /dev/null
@@ -0,0 +1,1760 @@
+<?php
+
+/**
+ * OpenID server protocol and logic.
+ * 
+ * Overview
+ *
+ * An OpenID server must perform three tasks:
+ *
+ *  1. Examine the incoming request to determine its nature and validity.
+ *  2. Make a decision about how to respond to this request.
+ *  3. Format the response according to the protocol.
+ * 
+ * The first and last of these tasks may performed by the {@link
+ * Auth_OpenID_Server::decodeRequest()} and {@link
+ * Auth_OpenID_Server::encodeResponse} methods.  Who gets to do the
+ * intermediate task -- deciding how to respond to the request -- will
+ * depend on what type of request it is.
+ *
+ * If it's a request to authenticate a user (a 'checkid_setup' or
+ * 'checkid_immediate' request), you need to decide if you will assert
+ * that this user may claim the identity in question.  Exactly how you
+ * do that is a matter of application policy, but it generally
+ * involves making sure the user has an account with your system and
+ * is logged in, checking to see if that identity is hers to claim,
+ * and verifying with the user that she does consent to releasing that
+ * information to the party making the request.
+ *
+ * Examine the properties of the {@link Auth_OpenID_CheckIDRequest}
+ * object, and if and when you've come to a decision, form a response
+ * by calling {@link Auth_OpenID_CheckIDRequest::answer()}.
+ *
+ * Other types of requests relate to establishing associations between
+ * client and server and verifing the authenticity of previous
+ * communications.  {@link Auth_OpenID_Server} contains all the logic
+ * and data necessary to respond to such requests; just pass it to
+ * {@link Auth_OpenID_Server::handleRequest()}.
+ *
+ * OpenID Extensions
+ * 
+ * Do you want to provide other information for your users in addition
+ * to authentication?  Version 1.2 of the OpenID protocol allows
+ * consumers to add extensions to their requests.  For example, with
+ * sites using the Simple Registration
+ * Extension
+ * (http://www.openidenabled.com/openid/simple-registration-extension/),
+ * a user can agree to have their nickname and e-mail address sent to
+ * a site when they sign up.
+ *
+ * Since extensions do not change the way OpenID authentication works,
+ * code to handle extension requests may be completely separate from
+ * the {@link Auth_OpenID_Request} class here.  But you'll likely want
+ * data sent back by your extension to be signed.  {@link
+ * Auth_OpenID_ServerResponse} provides methods with which you can add
+ * data to it which can be signed with the other data in the OpenID
+ * signature.
+ *
+ * For example:
+ *
+ * <pre>  // when request is a checkid_* request
+ *  $response = $request->answer(true);
+ *  // this will a signed 'openid.sreg.timezone' parameter to the response
+ *  response.addField('sreg', 'timezone', 'America/Los_Angeles')</pre>
+ *
+ * Stores
+ *
+ * The OpenID server needs to maintain state between requests in order
+ * to function.  Its mechanism for doing this is called a store.  The
+ * store interface is defined in Interface.php.  Additionally, several
+ * concrete store implementations are provided, so that most sites
+ * won't need to implement a custom store.  For a store backed by flat
+ * files on disk, see {@link Auth_OpenID_FileStore}.  For stores based
+ * on MySQL, SQLite, or PostgreSQL, see the {@link
+ * Auth_OpenID_SQLStore} subclasses.
+ *
+ * Upgrading
+ *
+ * The keys by which a server looks up associations in its store have
+ * changed in version 1.2 of this library.  If your store has entries
+ * created from version 1.0 code, you should empty it.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Required imports
+ */
+require_once "Auth/OpenID.php";
+require_once "Auth/OpenID/Association.php";
+require_once "Auth/OpenID/CryptUtil.php";
+require_once "Auth/OpenID/BigMath.php";
+require_once "Auth/OpenID/DiffieHellman.php";
+require_once "Auth/OpenID/KVForm.php";
+require_once "Auth/OpenID/TrustRoot.php";
+require_once "Auth/OpenID/ServerRequest.php";
+require_once "Auth/OpenID/Message.php";
+require_once "Auth/OpenID/Nonce.php";
+
+define('AUTH_OPENID_HTTP_OK', 200);
+define('AUTH_OPENID_HTTP_REDIRECT', 302);
+define('AUTH_OPENID_HTTP_ERROR', 400);
+
+/**
+ * @access private
+ */
+global $_Auth_OpenID_Request_Modes;
+$_Auth_OpenID_Request_Modes = array('checkid_setup',
+                                    'checkid_immediate');
+
+/**
+ * @access private
+ */
+define('Auth_OpenID_ENCODE_KVFORM', 'kfvorm');
+
+/**
+ * @access private
+ */
+define('Auth_OpenID_ENCODE_URL', 'URL/redirect');
+
+/**
+ * @access private
+ */
+define('Auth_OpenID_ENCODE_HTML_FORM', 'HTML form');
+
+/**
+ * @access private
+ */
+function Auth_OpenID_isError($obj, $cls = 'Auth_OpenID_ServerError')
+{
+    return is_a($obj, $cls);
+}
+
+/**
+ * An error class which gets instantiated and returned whenever an
+ * OpenID protocol error occurs.  Be prepared to use this in place of
+ * an ordinary server response.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_ServerError {
+    /**
+     * @access private
+     */
+    function Auth_OpenID_ServerError($message = null, $text = null,
+                                     $reference = null, $contact = null)
+    {
+        $this->message = $message;
+        $this->text = $text;
+        $this->contact = $contact;
+        $this->reference = $reference;
+    }
+
+    function getReturnTo()
+    {
+        if ($this->message &&
+            $this->message->hasKey(Auth_OpenID_OPENID_NS, 'return_to')) {
+            return $this->message->getArg(Auth_OpenID_OPENID_NS,
+                                          'return_to');
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Returns the return_to URL for the request which caused this
+     * error.
+     */
+    function hasReturnTo()
+    {
+        return $this->getReturnTo() !== null;
+    }
+
+    /**
+     * Encodes this error's response as a URL suitable for
+     * redirection.  If the response has no return_to, another
+     * Auth_OpenID_ServerError is returned.
+     */
+    function encodeToURL()
+    {
+        if (!$this->message) {
+            return null;
+        }
+
+        $msg = $this->toMessage();
+        return $msg->toURL($this->getReturnTo());
+    }
+
+    /**
+     * Encodes the response to key-value form.  This is a
+     * machine-readable format used to respond to messages which came
+     * directly from the consumer and not through the user-agent.  See
+     * the OpenID specification.
+     */
+    function encodeToKVForm()
+    {
+        return Auth_OpenID_KVForm::fromArray(
+                                      array('mode' => 'error',
+                                            'error' => $this->toString()));
+    }
+
+    function toFormMarkup($form_tag_attrs=null)
+    {
+        $msg = $this->toMessage();
+        return $msg->toFormMarkup($this->getReturnTo(), $form_tag_attrs);
+    }
+
+    function toHTML($form_tag_attrs=null)
+    {
+        return Auth_OpenID::autoSubmitHTML(
+                      $this->toFormMarkup($form_tag_attrs));
+    }
+
+    function toMessage()
+    {
+        // Generate a Message object for sending to the relying party,
+        // after encoding.
+        $namespace = $this->message->getOpenIDNamespace();
+        $reply = new Auth_OpenID_Message($namespace);
+        $reply->setArg(Auth_OpenID_OPENID_NS, 'mode', 'error');
+        $reply->setArg(Auth_OpenID_OPENID_NS, 'error', $this->toString());
+
+        if ($this->contact !== null) {
+            $reply->setArg(Auth_OpenID_OPENID_NS, 'contact', $this->contact);
+        }
+
+        if ($this->reference !== null) {
+            $reply->setArg(Auth_OpenID_OPENID_NS, 'reference',
+                           $this->reference);
+        }
+
+        return $reply;
+    }
+
+    /**
+     * Returns one of Auth_OpenID_ENCODE_URL,
+     * Auth_OpenID_ENCODE_KVFORM, or null, depending on the type of
+     * encoding expected for this error's payload.
+     */
+    function whichEncoding()
+    {
+        global $_Auth_OpenID_Request_Modes;
+
+        if ($this->hasReturnTo()) {
+            if ($this->message->isOpenID2() &&
+                (strlen($this->encodeToURL()) >
+                   Auth_OpenID_OPENID1_URL_LIMIT)) {
+                return Auth_OpenID_ENCODE_HTML_FORM;
+            } else {
+                return Auth_OpenID_ENCODE_URL;
+            }
+        }
+
+        if (!$this->message) {
+            return null;
+        }
+
+        $mode = $this->message->getArg(Auth_OpenID_OPENID_NS,
+                                       'mode');
+
+        if ($mode) {
+            if (!in_array($mode, $_Auth_OpenID_Request_Modes)) {
+                return Auth_OpenID_ENCODE_KVFORM;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns this error message.
+     */
+    function toString()
+    {
+        if ($this->text) {
+            return $this->text;
+        } else {
+            return get_class($this) . " error";
+        }
+    }
+}
+
+/**
+ * Error returned by the server code when a return_to is absent from a
+ * request.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_NoReturnToError extends Auth_OpenID_ServerError {
+    function Auth_OpenID_NoReturnToError($message = null,
+                                         $text = "No return_to URL available")
+    {
+        parent::Auth_OpenID_ServerError($message, $text);
+    }
+
+    function toString()
+    {
+        return "No return_to available";
+    }
+}
+
+/**
+ * An error indicating that the return_to URL is malformed.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_MalformedReturnURL extends Auth_OpenID_ServerError {
+    function Auth_OpenID_MalformedReturnURL($message, $return_to)
+    {
+        $this->return_to = $return_to;
+        parent::Auth_OpenID_ServerError($message, "malformed return_to URL");
+    }
+}
+
+/**
+ * This error is returned when the trust_root value is malformed.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_MalformedTrustRoot extends Auth_OpenID_ServerError {
+    function Auth_OpenID_MalformedTrustRoot($message = null,
+                                            $text = "Malformed trust root")
+    {
+        parent::Auth_OpenID_ServerError($message, $text);
+    }
+
+    function toString()
+    {
+        return "Malformed trust root";
+    }
+}
+
+/**
+ * The base class for all server request classes.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Request {
+    var $mode = null;
+}
+
+/**
+ * A request to verify the validity of a previous response.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_CheckAuthRequest extends Auth_OpenID_Request {
+    var $mode = "check_authentication";
+    var $invalidate_handle = null;
+
+    function Auth_OpenID_CheckAuthRequest($assoc_handle, $signed,
+                                          $invalidate_handle = null)
+    {
+        $this->assoc_handle = $assoc_handle;
+        $this->signed = $signed;
+        if ($invalidate_handle !== null) {
+            $this->invalidate_handle = $invalidate_handle;
+        }
+        $this->namespace = Auth_OpenID_OPENID2_NS;
+        $this->message = null;
+    }
+
+    function fromMessage($message, $server=null)
+    {
+        $required_keys = array('assoc_handle', 'sig', 'signed');
+
+        foreach ($required_keys as $k) {
+            if (!$message->getArg(Auth_OpenID_OPENID_NS, $k)) {
+                return new Auth_OpenID_ServerError($message,
+                    sprintf("%s request missing required parameter %s from \
+                            query", "check_authentication", $k));
+            }
+        }
+
+        $assoc_handle = $message->getArg(Auth_OpenID_OPENID_NS, 'assoc_handle');
+        $sig = $message->getArg(Auth_OpenID_OPENID_NS, 'sig');
+
+        $signed_list = $message->getArg(Auth_OpenID_OPENID_NS, 'signed');
+        $signed_list = explode(",", $signed_list);
+
+        $signed = $message;
+        if ($signed->hasKey(Auth_OpenID_OPENID_NS, 'mode')) {
+            $signed->setArg(Auth_OpenID_OPENID_NS, 'mode', 'id_res');
+        }
+
+        $result = new Auth_OpenID_CheckAuthRequest($assoc_handle, $signed);
+        $result->message = $message;
+        $result->sig = $sig;
+        $result->invalidate_handle = $message->getArg(Auth_OpenID_OPENID_NS,
+                                                      'invalidate_handle');
+        return $result;
+    }
+
+    function answer(&$signatory)
+    {
+        $is_valid = $signatory->verify($this->assoc_handle, $this->signed);
+
+        // Now invalidate that assoc_handle so it this checkAuth
+        // message cannot be replayed.
+        $signatory->invalidate($this->assoc_handle, true);
+        $response = new Auth_OpenID_ServerResponse($this);
+
+        $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                  'is_valid',
+                                  ($is_valid ? "true" : "false"));
+
+        if ($this->invalidate_handle) {
+            $assoc = $signatory->getAssociation($this->invalidate_handle,
+                                                false);
+            if (!$assoc) {
+                $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                          'invalidate_handle',
+                                          $this->invalidate_handle);
+            }
+        }
+        return $response;
+    }
+}
+
+/**
+ * A class implementing plaintext server sessions.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_PlainTextServerSession {
+    /**
+     * An object that knows how to handle association requests with no
+     * session type.
+     */
+    var $session_type = 'no-encryption';
+    var $needs_math = false;
+    var $allowed_assoc_types = array('HMAC-SHA1', 'HMAC-SHA256');
+
+    function fromMessage($unused_request)
+    {
+        return new Auth_OpenID_PlainTextServerSession();
+    }
+
+    function answer($secret)
+    {
+        return array('mac_key' => base64_encode($secret));
+    }
+}
+
+/**
+ * A class implementing DH-SHA1 server sessions.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_DiffieHellmanSHA1ServerSession {
+    /**
+     * An object that knows how to handle association requests with
+     * the Diffie-Hellman session type.
+     */
+
+    var $session_type = 'DH-SHA1';
+    var $needs_math = true;
+    var $allowed_assoc_types = array('HMAC-SHA1');
+    var $hash_func = 'Auth_OpenID_SHA1';
+
+    function Auth_OpenID_DiffieHellmanSHA1ServerSession($dh, $consumer_pubkey)
+    {
+        $this->dh = $dh;
+        $this->consumer_pubkey = $consumer_pubkey;
+    }
+
+    function getDH($message)
+    {
+        $dh_modulus = $message->getArg(Auth_OpenID_OPENID_NS, 'dh_modulus');
+        $dh_gen = $message->getArg(Auth_OpenID_OPENID_NS, 'dh_gen');
+
+        if ((($dh_modulus === null) && ($dh_gen !== null)) ||
+            (($dh_gen === null) && ($dh_modulus !== null))) {
+
+            if ($dh_modulus === null) {
+                $missing = 'modulus';
+            } else {
+                $missing = 'generator';
+            }
+
+            return new Auth_OpenID_ServerError($message,
+                                'If non-default modulus or generator is '.
+                                'supplied, both must be supplied.  Missing '.
+                                $missing);
+        }
+
+        $lib =& Auth_OpenID_getMathLib();
+
+        if ($dh_modulus || $dh_gen) {
+            $dh_modulus = $lib->base64ToLong($dh_modulus);
+            $dh_gen = $lib->base64ToLong($dh_gen);
+            if ($lib->cmp($dh_modulus, 0) == 0 ||
+                $lib->cmp($dh_gen, 0) == 0) {
+                return new Auth_OpenID_ServerError(
+                  $message, "Failed to parse dh_mod or dh_gen");
+            }
+            $dh = new Auth_OpenID_DiffieHellman($dh_modulus, $dh_gen);
+        } else {
+            $dh = new Auth_OpenID_DiffieHellman();
+        }
+
+        $consumer_pubkey = $message->getArg(Auth_OpenID_OPENID_NS,
+                                            'dh_consumer_public');
+        if ($consumer_pubkey === null) {
+            return new Auth_OpenID_ServerError($message,
+                                  'Public key for DH-SHA1 session '.
+                                  'not found in query');
+        }
+
+        $consumer_pubkey =
+            $lib->base64ToLong($consumer_pubkey);
+
+        if ($consumer_pubkey === false) {
+            return new Auth_OpenID_ServerError($message,
+                                       "dh_consumer_public is not base64");
+        }
+
+        return array($dh, $consumer_pubkey);
+    }
+
+    function fromMessage($message)
+    {
+        $result = Auth_OpenID_DiffieHellmanSHA1ServerSession::getDH($message);
+
+        if (is_a($result, 'Auth_OpenID_ServerError')) {
+            return $result;
+        } else {
+            list($dh, $consumer_pubkey) = $result;
+            return new Auth_OpenID_DiffieHellmanSHA1ServerSession($dh,
+                                                    $consumer_pubkey);
+        }
+    }
+
+    function answer($secret)
+    {
+        $lib =& Auth_OpenID_getMathLib();
+        $mac_key = $this->dh->xorSecret($this->consumer_pubkey, $secret,
+                                        $this->hash_func);
+        return array(
+           'dh_server_public' =>
+                $lib->longToBase64($this->dh->public),
+           'enc_mac_key' => base64_encode($mac_key));
+    }
+}
+
+/**
+ * A class implementing DH-SHA256 server sessions.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_DiffieHellmanSHA256ServerSession
+      extends Auth_OpenID_DiffieHellmanSHA1ServerSession {
+
+    var $session_type = 'DH-SHA256';
+    var $hash_func = 'Auth_OpenID_SHA256';
+    var $allowed_assoc_types = array('HMAC-SHA256');
+
+    function fromMessage($message)
+    {
+        $result = Auth_OpenID_DiffieHellmanSHA1ServerSession::getDH($message);
+
+        if (is_a($result, 'Auth_OpenID_ServerError')) {
+            return $result;
+        } else {
+            list($dh, $consumer_pubkey) = $result;
+            return new Auth_OpenID_DiffieHellmanSHA256ServerSession($dh,
+                                                      $consumer_pubkey);
+        }
+    }
+}
+
+/**
+ * A request to associate with the server.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AssociateRequest extends Auth_OpenID_Request {
+    var $mode = "associate";
+
+    function getSessionClasses()
+    {
+        return array(
+          'no-encryption' => 'Auth_OpenID_PlainTextServerSession',
+          'DH-SHA1' => 'Auth_OpenID_DiffieHellmanSHA1ServerSession',
+          'DH-SHA256' => 'Auth_OpenID_DiffieHellmanSHA256ServerSession');
+    }
+
+    function Auth_OpenID_AssociateRequest(&$session, $assoc_type)
+    {
+        $this->session =& $session;
+        $this->namespace = Auth_OpenID_OPENID2_NS;
+        $this->assoc_type = $assoc_type;
+    }
+
+    function fromMessage($message, $server=null)
+    {
+        if ($message->isOpenID1()) {
+            $session_type = $message->getArg(Auth_OpenID_OPENID_NS,
+                                             'session_type');
+
+            if ($session_type == 'no-encryption') {
+                // oidutil.log('Received OpenID 1 request with a no-encryption '
+                //             'assocaition session type. Continuing anyway.')
+            } else if (!$session_type) {
+                $session_type = 'no-encryption';
+            }
+        } else {
+            $session_type = $message->getArg(Auth_OpenID_OPENID_NS,
+                                             'session_type');
+            if ($session_type === null) {
+                return new Auth_OpenID_ServerError($message,
+                  "session_type missing from request");
+            }
+        }
+
+        $session_class = Auth_OpenID::arrayGet(
+           Auth_OpenID_AssociateRequest::getSessionClasses(),
+           $session_type);
+
+        if ($session_class === null) {
+            return new Auth_OpenID_ServerError($message,
+                                               "Unknown session type " .
+                                               $session_type);
+        }
+
+        $session = call_user_func(array($session_class, 'fromMessage'),
+                                  $message);
+        if (is_a($session, 'Auth_OpenID_ServerError')) {
+            return $session;
+        }
+
+        $assoc_type = $message->getArg(Auth_OpenID_OPENID_NS,
+                                       'assoc_type', 'HMAC-SHA1');
+
+        if (!in_array($assoc_type, $session->allowed_assoc_types)) {
+            $fmt = "Session type %s does not support association type %s";
+            return new Auth_OpenID_ServerError($message,
+              sprintf($fmt, $session_type, $assoc_type));
+        }
+
+        $obj = new Auth_OpenID_AssociateRequest($session, $assoc_type);
+        $obj->message = $message;
+        $obj->namespace = $message->getOpenIDNamespace();
+        return $obj;
+    }
+
+    function answer($assoc)
+    {
+        $response = new Auth_OpenID_ServerResponse($this);
+        $response->fields->updateArgs(Auth_OpenID_OPENID_NS,
+           array(
+                 'expires_in' => sprintf('%d', $assoc->getExpiresIn()),
+                 'assoc_type' => $this->assoc_type,
+                 'assoc_handle' => $assoc->handle));
+
+        $response->fields->updateArgs(Auth_OpenID_OPENID_NS,
+           $this->session->answer($assoc->secret));
+
+        if (! ($this->session->session_type == 'no-encryption' 
+               && $this->message->isOpenID1())) {
+            $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                      'session_type',
+                                      $this->session->session_type);
+        }
+
+        return $response;
+    }
+
+    function answerUnsupported($text_message,
+                               $preferred_association_type=null,
+                               $preferred_session_type=null)
+    {
+        if ($this->message->isOpenID1()) {
+            return new Auth_OpenID_ServerError($this->message);
+        }
+
+        $response = new Auth_OpenID_ServerResponse($this);
+        $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                  'error_code', 'unsupported-type');
+        $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                  'error', $text_message);
+
+        if ($preferred_association_type) {
+            $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                      'assoc_type',
+                                      $preferred_association_type);
+        }
+
+        if ($preferred_session_type) {
+            $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                      'session_type',
+                                      $preferred_session_type);
+        }
+
+        return $response;
+    }
+}
+
+/**
+ * A request to confirm the identity of a user.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_CheckIDRequest extends Auth_OpenID_Request {
+    /**
+     * Return-to verification callback.  Default is
+     * Auth_OpenID_verifyReturnTo from TrustRoot.php.
+     */
+    var $verifyReturnTo = 'Auth_OpenID_verifyReturnTo';
+
+    /**
+     * The mode of this request.
+     */
+    var $mode = "checkid_setup"; // or "checkid_immediate"
+
+    /**
+     * Whether this request is for immediate mode.
+     */
+    var $immediate = false;
+
+    /**
+     * The trust_root value for this request.
+     */
+    var $trust_root = null;
+
+    /**
+     * The OpenID namespace for this request.
+     * deprecated since version 2.0.2
+     */
+    var $namespace;
+    
+    function make(&$message, $identity, $return_to, $trust_root = null,
+                  $immediate = false, $assoc_handle = null, $server = null)
+    {
+        if ($server === null) {
+            return new Auth_OpenID_ServerError($message,
+                                               "server must not be null");
+        }
+
+        if ($return_to &&
+            !Auth_OpenID_TrustRoot::_parse($return_to)) {
+            return new Auth_OpenID_MalformedReturnURL($message, $return_to);
+        }
+
+        $r = new Auth_OpenID_CheckIDRequest($identity, $return_to,
+                                            $trust_root, $immediate,
+                                            $assoc_handle, $server);
+
+        $r->namespace = $message->getOpenIDNamespace();
+        $r->message =& $message;
+
+        if (!$r->trustRootValid()) {
+            return new Auth_OpenID_UntrustedReturnURL($message,
+                                                      $return_to,
+                                                      $trust_root);
+        } else {
+            return $r;
+        }
+    }
+
+    function Auth_OpenID_CheckIDRequest($identity, $return_to,
+                                        $trust_root = null, $immediate = false,
+                                        $assoc_handle = null, $server = null,
+                                        $claimed_id = null)
+    {
+        $this->namespace = Auth_OpenID_OPENID2_NS;
+        $this->assoc_handle = $assoc_handle;
+        $this->identity = $identity;
+        if ($claimed_id === null) {
+            $this->claimed_id = $identity;
+        } else {
+            $this->claimed_id = $claimed_id;
+        }
+        $this->return_to = $return_to;
+        $this->trust_root = $trust_root;
+        $this->server =& $server;
+
+        if ($immediate) {
+            $this->immediate = true;
+            $this->mode = "checkid_immediate";
+        } else {
+            $this->immediate = false;
+            $this->mode = "checkid_setup";
+        }
+    }
+
+    function equals($other)
+    {
+        return (
+                (is_a($other, 'Auth_OpenID_CheckIDRequest')) &&
+                ($this->namespace == $other->namespace) &&
+                ($this->assoc_handle == $other->assoc_handle) &&
+                ($this->identity == $other->identity) &&
+                ($this->claimed_id == $other->claimed_id) &&
+                ($this->return_to == $other->return_to) &&
+                ($this->trust_root == $other->trust_root));
+    }
+
+    /*
+     * Does the relying party publish the return_to URL for this
+     * response under the realm? It is up to the provider to set a
+     * policy for what kinds of realms should be allowed. This
+     * return_to URL verification reduces vulnerability to data-theft
+     * attacks based on open proxies, corss-site-scripting, or open
+     * redirectors.
+     *
+     * This check should only be performed after making sure that the
+     * return_to URL matches the realm.
+     *
+     * @return true if the realm publishes a document with the
+     * return_to URL listed, false if not or if discovery fails
+     */
+    function returnToVerified()
+    {
+        return call_user_func_array($this->verifyReturnTo,
+                                    array($this->trust_root, $this->return_to));
+    }
+
+    function fromMessage(&$message, $server)
+    {
+        $mode = $message->getArg(Auth_OpenID_OPENID_NS, 'mode');
+        $immediate = null;
+
+        if ($mode == "checkid_immediate") {
+            $immediate = true;
+            $mode = "checkid_immediate";
+        } else {
+            $immediate = false;
+            $mode = "checkid_setup";
+        }
+
+        $return_to = $message->getArg(Auth_OpenID_OPENID_NS,
+                                      'return_to');
+
+        if (($message->isOpenID1()) &&
+            (!$return_to)) {
+            $fmt = "Missing required field 'return_to' from checkid request";
+            return new Auth_OpenID_ServerError($message, $fmt);
+        }
+
+        $identity = $message->getArg(Auth_OpenID_OPENID_NS,
+                                     'identity');
+        $claimed_id = $message->getArg(Auth_OpenID_OPENID_NS, 'claimed_id');
+        if ($message->isOpenID1()) {
+            if ($identity === null) {
+                $s = "OpenID 1 message did not contain openid.identity";
+                return new Auth_OpenID_ServerError($message, $s);
+            }
+        } else {
+            if ($identity && !$claimed_id) {
+                $s = "OpenID 2.0 message contained openid.identity but not " .
+                  "claimed_id";
+                return new Auth_OpenID_ServerError($message, $s);
+            } else if ($claimed_id && !$identity) {
+                $s = "OpenID 2.0 message contained openid.claimed_id " .
+                  "but not identity";
+                return new Auth_OpenID_ServerError($message, $s);
+            }
+        }
+
+        // There's a case for making self.trust_root be a TrustRoot
+        // here.  But if TrustRoot isn't currently part of the
+        // "public" API, I'm not sure it's worth doing.
+        if ($message->isOpenID1()) {
+            $trust_root_param = 'trust_root';
+        } else {
+            $trust_root_param = 'realm';
+        }
+        $trust_root = $message->getArg(Auth_OpenID_OPENID_NS, 
+                                       $trust_root_param);
+        if (! $trust_root) {
+            $trust_root = $return_to;
+        }
+
+        if (! $message->isOpenID1() && 
+            ($return_to === null) &&
+            ($trust_root === null)) {
+            return new Auth_OpenID_ServerError($message,
+              "openid.realm required when openid.return_to absent");
+        }
+
+        $assoc_handle = $message->getArg(Auth_OpenID_OPENID_NS,
+                                         'assoc_handle');
+
+        $obj = Auth_OpenID_CheckIDRequest::make($message,
+                                                $identity,
+                                                $return_to,
+                                                $trust_root,
+                                                $immediate,
+                                                $assoc_handle,
+                                                $server);
+
+        if (is_a($obj, 'Auth_OpenID_ServerError')) {
+            return $obj;
+        }
+
+        $obj->claimed_id = $claimed_id;
+
+        return $obj;
+    }
+
+    function idSelect()
+    {
+        // Is the identifier to be selected by the IDP?
+        // So IDPs don't have to import the constant
+        return $this->identity == Auth_OpenID_IDENTIFIER_SELECT;
+    }
+
+    function trustRootValid()
+    {
+        if (!$this->trust_root) {
+            return true;
+        }
+
+        $tr = Auth_OpenID_TrustRoot::_parse($this->trust_root);
+        if ($tr === false) {
+            return new Auth_OpenID_MalformedTrustRoot($this->message,
+                                                      $this->trust_root);
+        }
+
+        if ($this->return_to !== null) {
+            return Auth_OpenID_TrustRoot::match($this->trust_root,
+                                                $this->return_to);
+        } else {
+            return true;
+        }
+    }
+
+    /**
+     * Respond to this request.  Return either an
+     * {@link Auth_OpenID_ServerResponse} or
+     * {@link Auth_OpenID_ServerError}.
+     *
+     * @param bool $allow Allow this user to claim this identity, and
+     * allow the consumer to have this information?
+     *
+     * @param string $server_url DEPRECATED.  Passing $op_endpoint to
+     * the {@link Auth_OpenID_Server} constructor makes this optional.
+     *
+     * When an OpenID 1.x immediate mode request does not succeed, it
+     * gets back a URL where the request may be carried out in a
+     * not-so-immediate fashion.  Pass my URL in here (the fully
+     * qualified address of this server's endpoint, i.e.
+     * http://example.com/server), and I will use it as a base for the
+     * URL for a new request.
+     *
+     * Optional for requests where {@link $immediate} is false or
+     * $allow is true.
+     *
+     * @param string $identity The OP-local identifier to answer with.
+     * Only for use when the relying party requested identifier
+     * selection.
+     *
+     * @param string $claimed_id The claimed identifier to answer
+     * with, for use with identifier selection in the case where the
+     * claimed identifier and the OP-local identifier differ,
+     * i.e. when the claimed_id uses delegation.
+     *
+     * If $identity is provided but this is not, $claimed_id will
+     * default to the value of $identity.  When answering requests
+     * that did not ask for identifier selection, the response
+     * $claimed_id will default to that of the request.
+     *
+     * This parameter is new in OpenID 2.0.
+     *
+     * @return mixed
+     */
+    function answer($allow, $server_url = null, $identity = null,
+                    $claimed_id = null)
+    {
+        if (!$this->return_to) {
+            return new Auth_OpenID_NoReturnToError();
+        }
+
+        if (!$server_url) {
+            if ((!$this->message->isOpenID1()) &&
+                (!$this->server->op_endpoint)) {
+                return new Auth_OpenID_ServerError(null,
+                  "server should be constructed with op_endpoint to " .
+                  "respond to OpenID 2.0 messages.");
+            }
+
+            $server_url = $this->server->op_endpoint;
+        }
+
+        if ($allow) {
+            $mode = 'id_res';
+        } else if ($this->message->isOpenID1()) {
+            if ($this->immediate) {
+                $mode = 'id_res';
+            } else {
+                $mode = 'cancel';
+            }
+        } else {
+            if ($this->immediate) {
+                $mode = 'setup_needed';
+            } else {
+                $mode = 'cancel';
+            }
+        }
+
+        if (!$this->trustRootValid()) {
+            return new Auth_OpenID_UntrustedReturnURL(null,
+                                                      $this->return_to,
+                                                      $this->trust_root);
+        }
+
+        $response = new Auth_OpenID_ServerResponse($this);
+
+        if ($claimed_id &&
+            ($this->message->isOpenID1())) {
+            return new Auth_OpenID_ServerError(null,
+              "claimed_id is new in OpenID 2.0 and not " .
+              "available for ".$this->namespace);
+        }
+
+        if ($identity && !$claimed_id) {
+            $claimed_id = $identity;
+        }
+
+        if ($allow) {
+
+            if ($this->identity == Auth_OpenID_IDENTIFIER_SELECT) {
+                if (!$identity) {
+                    return new Auth_OpenID_ServerError(null,
+                      "This request uses IdP-driven identifier selection.  " .
+                      "You must supply an identifier in the response.");
+                }
+
+                $response_identity = $identity;
+                $response_claimed_id = $claimed_id;
+
+            } else if ($this->identity) {
+                if ($identity &&
+                    ($this->identity != $identity)) {
+                    $fmt = "Request was for %s, cannot reply with identity %s";
+                    return new Auth_OpenID_ServerError(null,
+                      sprintf($fmt, $this->identity, $identity));
+                }
+
+                $response_identity = $this->identity;
+                $response_claimed_id = $this->claimed_id;
+            } else {
+                if ($identity) {
+                    return new Auth_OpenID_ServerError(null,
+                      "This request specified no identity and " .
+                      "you supplied ".$identity);
+                }
+
+                $response_identity = null;
+            }
+
+            if (($this->message->isOpenID1()) &&
+                ($response_identity === null)) {
+                return new Auth_OpenID_ServerError(null,
+                  "Request was an OpenID 1 request, so response must " .
+                  "include an identifier.");
+            }
+
+            $response->fields->updateArgs(Auth_OpenID_OPENID_NS,
+                   array('mode' => $mode,
+                         'return_to' => $this->return_to,
+                         'response_nonce' => Auth_OpenID_mkNonce()));
+
+            if (!$this->message->isOpenID1()) {
+                $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                          'op_endpoint', $server_url);
+            }
+
+            if ($response_identity !== null) {
+                $response->fields->setArg(
+                                          Auth_OpenID_OPENID_NS,
+                                          'identity',
+                                          $response_identity);
+                if ($this->message->isOpenID2()) {
+                    $response->fields->setArg(
+                                              Auth_OpenID_OPENID_NS,
+                                              'claimed_id',
+                                              $response_claimed_id);
+                }
+            }
+
+        } else {
+            $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                      'mode', $mode);
+
+            if ($this->immediate) {
+                if (($this->message->isOpenID1()) &&
+                    (!$server_url)) {
+                    return new Auth_OpenID_ServerError(null,
+                                 'setup_url is required for $allow=false \
+                                  in OpenID 1.x immediate mode.');
+                }
+
+                $setup_request =& new Auth_OpenID_CheckIDRequest(
+                                                $this->identity,
+                                                $this->return_to,
+                                                $this->trust_root,
+                                                false,
+                                                $this->assoc_handle,
+                                                $this->server,
+                                                $this->claimed_id);
+                $setup_request->message = $this->message;
+
+                $setup_url = $setup_request->encodeToURL($server_url);
+
+                if ($setup_url === null) {
+                    return new Auth_OpenID_NoReturnToError();
+                }
+
+                $response->fields->setArg(Auth_OpenID_OPENID_NS,
+                                          'user_setup_url',
+                                          $setup_url);
+            }
+        }
+
+        return $response;
+    }
+
+    function encodeToURL($server_url)
+    {
+        if (!$this->return_to) {
+            return new Auth_OpenID_NoReturnToError();
+        }
+
+        // Imported from the alternate reality where these classes are
+        // used in both the client and server code, so Requests are
+        // Encodable too.  That's right, code imported from alternate
+        // realities all for the love of you, id_res/user_setup_url.
+
+        $q = array('mode' => $this->mode,
+                   'identity' => $this->identity,
+                   'claimed_id' => $this->claimed_id,
+                   'return_to' => $this->return_to);
+
+        if ($this->trust_root) {
+            if ($this->message->isOpenID1()) {
+                $q['trust_root'] = $this->trust_root;
+            } else {
+                $q['realm'] = $this->trust_root;
+            }
+        }
+
+        if ($this->assoc_handle) {
+            $q['assoc_handle'] = $this->assoc_handle;
+        }
+
+        $response = new Auth_OpenID_Message(
+            $this->message->getOpenIDNamespace());
+        $response->updateArgs(Auth_OpenID_OPENID_NS, $q);
+        return $response->toURL($server_url);
+    }
+
+    function getCancelURL()
+    {
+        if (!$this->return_to) {
+            return new Auth_OpenID_NoReturnToError();
+        }
+
+        if ($this->immediate) {
+            return new Auth_OpenID_ServerError(null,
+                                               "Cancel is not an appropriate \
+                                               response to immediate mode \
+                                               requests.");
+        }
+
+        $response = new Auth_OpenID_Message(
+            $this->message->getOpenIDNamespace());
+        $response->setArg(Auth_OpenID_OPENID_NS, 'mode', 'cancel');
+        return $response->toURL($this->return_to);
+    }
+}
+
+/**
+ * This class encapsulates the response to an OpenID server request.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_ServerResponse {
+
+    function Auth_OpenID_ServerResponse(&$request)
+    {
+        $this->request =& $request;
+        $this->fields = new Auth_OpenID_Message($this->request->namespace);
+    }
+
+    function whichEncoding()
+    {
+      global $_Auth_OpenID_Request_Modes;
+
+        if (in_array($this->request->mode, $_Auth_OpenID_Request_Modes)) {
+            if ($this->fields->isOpenID2() &&
+                (strlen($this->encodeToURL()) >
+                   Auth_OpenID_OPENID1_URL_LIMIT)) {
+                return Auth_OpenID_ENCODE_HTML_FORM;
+            } else {
+                return Auth_OpenID_ENCODE_URL;
+            }
+        } else {
+            return Auth_OpenID_ENCODE_KVFORM;
+        }
+    }
+
+    /*
+     * Returns the form markup for this response.
+     *
+     * @return str
+     */
+    function toFormMarkup($form_tag_attrs=null)
+    {
+        return $this->fields->toFormMarkup($this->request->return_to,
+                                           $form_tag_attrs);
+    }
+
+    /*
+     * Returns an HTML document containing the form markup for this
+     * response that autosubmits with javascript.
+     */
+    function toHTML()
+    {
+        return Auth_OpenID::autoSubmitHTML($this->toFormMarkup());
+    }
+
+    /*
+     * Returns True if this response's encoding is ENCODE_HTML_FORM.
+     * Convenience method for server authors.
+     *
+     * @return bool
+     */
+    function renderAsForm()
+    {
+        return $this->whichEncoding() == Auth_OpenID_ENCODE_HTML_FORM;
+    }
+
+
+    function encodeToURL()
+    {
+        return $this->fields->toURL($this->request->return_to);
+    }
+
+    function addExtension($extension_response)
+    {
+        $extension_response->toMessage($this->fields);
+    }
+
+    function needsSigning()
+    {
+        return $this->fields->getArg(Auth_OpenID_OPENID_NS,
+                                     'mode') == 'id_res';
+    }
+
+    function encodeToKVForm()
+    {
+        return $this->fields->toKVForm();
+    }
+}
+
+/**
+ * A web-capable response object which you can use to generate a
+ * user-agent response.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_WebResponse {
+    var $code = AUTH_OPENID_HTTP_OK;
+    var $body = "";
+
+    function Auth_OpenID_WebResponse($code = null, $headers = null,
+                                     $body = null)
+    {
+        if ($code) {
+            $this->code = $code;
+        }
+
+        if ($headers !== null) {
+            $this->headers = $headers;
+        } else {
+            $this->headers = array();
+        }
+
+        if ($body !== null) {
+            $this->body = $body;
+        }
+    }
+}
+
+/**
+ * Responsible for the signature of query data and the verification of
+ * OpenID signature values.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Signatory {
+
+    // = 14 * 24 * 60 * 60; # 14 days, in seconds
+    var $SECRET_LIFETIME = 1209600;
+
+    // keys have a bogus server URL in them because the filestore
+    // really does expect that key to be a URL.  This seems a little
+    // silly for the server store, since I expect there to be only one
+    // server URL.
+    var $normal_key = 'http://localhost/|normal';
+    var $dumb_key = 'http://localhost/|dumb';
+
+    /**
+     * Create a new signatory using a given store.
+     */
+    function Auth_OpenID_Signatory(&$store)
+    {
+        // assert store is not None
+        $this->store =& $store;
+    }
+
+    /**
+     * Verify, using a given association handle, a signature with
+     * signed key-value pairs from an HTTP request.
+     */
+    function verify($assoc_handle, $message)
+    {
+        $assoc = $this->getAssociation($assoc_handle, true);
+        if (!$assoc) {
+            // oidutil.log("failed to get assoc with handle %r to verify sig %r"
+            //             % (assoc_handle, sig))
+            return false;
+        }
+
+        return $assoc->checkMessageSignature($message);
+    }
+
+    /**
+     * Given a response, sign the fields in the response's 'signed'
+     * list, and insert the signature into the response.
+     */
+    function sign($response)
+    {
+        $signed_response = $response;
+        $assoc_handle = $response->request->assoc_handle;
+
+        if ($assoc_handle) {
+            // normal mode
+            $assoc = $this->getAssociation($assoc_handle, false, false);
+            if (!$assoc || ($assoc->getExpiresIn() <= 0)) {
+                // fall back to dumb mode
+                $signed_response->fields->setArg(Auth_OpenID_OPENID_NS,
+                             'invalidate_handle', $assoc_handle);
+                $assoc_type = ($assoc ? $assoc->assoc_type : 'HMAC-SHA1');
+
+                if ($assoc && ($assoc->getExpiresIn() <= 0)) {
+                    $this->invalidate($assoc_handle, false);
+                }
+
+                $assoc = $this->createAssociation(true, $assoc_type);
+            }
+        } else {
+            // dumb mode.
+            $assoc = $this->createAssociation(true);
+        }
+
+        $signed_response->fields = $assoc->signMessage(
+                                      $signed_response->fields);
+        return $signed_response;
+    }
+
+    /**
+     * Make a new association.
+     */
+    function createAssociation($dumb = true, $assoc_type = 'HMAC-SHA1')
+    {
+        $secret = Auth_OpenID_CryptUtil::getBytes(
+                    Auth_OpenID_getSecretSize($assoc_type));
+
+        $uniq = base64_encode(Auth_OpenID_CryptUtil::getBytes(4));
+        $handle = sprintf('{%s}{%x}{%s}', $assoc_type, intval(time()), $uniq);
+
+        $assoc = Auth_OpenID_Association::fromExpiresIn(
+                      $this->SECRET_LIFETIME, $handle, $secret, $assoc_type);
+
+        if ($dumb) {
+            $key = $this->dumb_key;
+        } else {
+            $key = $this->normal_key;
+        }
+
+        $this->store->storeAssociation($key, $assoc);
+        return $assoc;
+    }
+
+    /**
+     * Given an association handle, get the association from the
+     * store, or return a ServerError or null if something goes wrong.
+     */
+    function getAssociation($assoc_handle, $dumb, $check_expiration=true)
+    {
+        if ($assoc_handle === null) {
+            return new Auth_OpenID_ServerError(null,
+                                     "assoc_handle must not be null");
+        }
+
+        if ($dumb) {
+            $key = $this->dumb_key;
+        } else {
+            $key = $this->normal_key;
+        }
+
+        $assoc = $this->store->getAssociation($key, $assoc_handle);
+
+        if (($assoc !== null) && ($assoc->getExpiresIn() <= 0)) {
+            if ($check_expiration) {
+                $this->store->removeAssociation($key, $assoc_handle);
+                $assoc = null;
+            }
+        }
+
+        return $assoc;
+    }
+
+    /**
+     * Invalidate a given association handle.
+     */
+    function invalidate($assoc_handle, $dumb)
+    {
+        if ($dumb) {
+            $key = $this->dumb_key;
+        } else {
+            $key = $this->normal_key;
+        }
+        $this->store->removeAssociation($key, $assoc_handle);
+    }
+}
+
+/**
+ * Encode an {@link Auth_OpenID_ServerResponse} to an
+ * {@link Auth_OpenID_WebResponse}.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Encoder {
+
+    var $responseFactory = 'Auth_OpenID_WebResponse';
+
+    /**
+     * Encode an {@link Auth_OpenID_ServerResponse} and return an
+     * {@link Auth_OpenID_WebResponse}.
+     */
+    function encode(&$response)
+    {
+        $cls = $this->responseFactory;
+
+        $encode_as = $response->whichEncoding();
+        if ($encode_as == Auth_OpenID_ENCODE_KVFORM) {
+            $wr = new $cls(null, null, $response->encodeToKVForm());
+            if (is_a($response, 'Auth_OpenID_ServerError')) {
+                $wr->code = AUTH_OPENID_HTTP_ERROR;
+            }
+        } else if ($encode_as == Auth_OpenID_ENCODE_URL) {
+            $location = $response->encodeToURL();
+            $wr = new $cls(AUTH_OPENID_HTTP_REDIRECT,
+                           array('location' => $location));
+        } else if ($encode_as == Auth_OpenID_ENCODE_HTML_FORM) {
+          $wr = new $cls(AUTH_OPENID_HTTP_OK, array(),
+                         $response->toFormMarkup());
+        } else {
+            return new Auth_OpenID_EncodingError($response);
+        }
+        return $wr;
+    }
+}
+
+/**
+ * An encoder which also takes care of signing fields when required.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_SigningEncoder extends Auth_OpenID_Encoder {
+
+    function Auth_OpenID_SigningEncoder(&$signatory)
+    {
+        $this->signatory =& $signatory;
+    }
+
+    /**
+     * Sign an {@link Auth_OpenID_ServerResponse} and return an
+     * {@link Auth_OpenID_WebResponse}.
+     */
+    function encode(&$response)
+    {
+        // the isinstance is a bit of a kludge... it means there isn't
+        // really an adapter to make the interfaces quite match.
+        if (!is_a($response, 'Auth_OpenID_ServerError') &&
+            $response->needsSigning()) {
+
+            if (!$this->signatory) {
+                return new Auth_OpenID_ServerError(null,
+                                       "Must have a store to sign request");
+            }
+
+            if ($response->fields->hasKey(Auth_OpenID_OPENID_NS, 'sig')) {
+                return new Auth_OpenID_AlreadySigned($response);
+            }
+            $response = $this->signatory->sign($response);
+        }
+
+        return parent::encode($response);
+    }
+}
+
+/**
+ * Decode an incoming query into an Auth_OpenID_Request.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Decoder {
+
+    function Auth_OpenID_Decoder(&$server)
+    {
+        $this->server =& $server;
+
+        $this->handlers = array(
+            'checkid_setup' => 'Auth_OpenID_CheckIDRequest',
+            'checkid_immediate' => 'Auth_OpenID_CheckIDRequest',
+            'check_authentication' => 'Auth_OpenID_CheckAuthRequest',
+            'associate' => 'Auth_OpenID_AssociateRequest'
+            );
+    }
+
+    /**
+     * Given an HTTP query in an array (key-value pairs), decode it
+     * into an Auth_OpenID_Request object.
+     */
+    function decode($query)
+    {
+        if (!$query) {
+            return null;
+        }
+
+        $message = Auth_OpenID_Message::fromPostArgs($query);
+
+        if ($message === null) {
+            /*
+             * It's useful to have a Message attached to a
+             * ProtocolError, so we override the bad ns value to build
+             * a Message out of it.  Kinda kludgy, since it's made of
+             * lies, but the parts that aren't lies are more useful
+             * than a 'None'.
+             */
+            $old_ns = $query['openid.ns'];
+
+            $query['openid.ns'] = Auth_OpenID_OPENID2_NS;
+            $message = Auth_OpenID_Message::fromPostArgs($query);
+            return new Auth_OpenID_ServerError(
+                  $message,
+                  sprintf("Invalid OpenID namespace URI: %s", $old_ns));
+        }
+
+        $mode = $message->getArg(Auth_OpenID_OPENID_NS, 'mode');
+        if (!$mode) {
+            return new Auth_OpenID_ServerError($message,
+                                               "No mode value in message");
+        }
+
+        if (Auth_OpenID::isFailure($mode)) {
+            return new Auth_OpenID_ServerError($message,
+                                               $mode->message);
+        }
+
+        $handlerCls = Auth_OpenID::arrayGet($this->handlers, $mode,
+                                            $this->defaultDecoder($message));
+
+        if (!is_a($handlerCls, 'Auth_OpenID_ServerError')) {
+            return call_user_func_array(array($handlerCls, 'fromMessage'),
+                                        array($message, $this->server));
+        } else {
+            return $handlerCls;
+        }
+    }
+
+    function defaultDecoder($message)
+    {
+        $mode = $message->getArg(Auth_OpenID_OPENID_NS, 'mode');
+
+        if (Auth_OpenID::isFailure($mode)) {
+            return new Auth_OpenID_ServerError($message,
+                                               $mode->message);
+        }
+
+        return new Auth_OpenID_ServerError($message,
+                       sprintf("Unrecognized OpenID mode %s", $mode));
+    }
+}
+
+/**
+ * An error that indicates an encoding problem occurred.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_EncodingError {
+    function Auth_OpenID_EncodingError(&$response)
+    {
+        $this->response =& $response;
+    }
+}
+
+/**
+ * An error that indicates that a response was already signed.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_AlreadySigned extends Auth_OpenID_EncodingError {
+    // This response is already signed.
+}
+
+/**
+ * An error that indicates that the given return_to is not under the
+ * given trust_root.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_UntrustedReturnURL extends Auth_OpenID_ServerError {
+    function Auth_OpenID_UntrustedReturnURL($message, $return_to,
+                                            $trust_root)
+    {
+        parent::Auth_OpenID_ServerError($message, "Untrusted return_to URL");
+        $this->return_to = $return_to;
+        $this->trust_root = $trust_root;
+    }
+
+    function toString()
+    {
+        return sprintf("return_to %s not under trust_root %s",
+                       $this->return_to, $this->trust_root);
+    }
+}
+
+/**
+ * I handle requests for an OpenID server.
+ *
+ * Some types of requests (those which are not checkid requests) may
+ * be handed to my {@link handleRequest} method, and I will take care
+ * of it and return a response.
+ *
+ * For your convenience, I also provide an interface to {@link
+ * Auth_OpenID_Decoder::decode()} and {@link
+ * Auth_OpenID_SigningEncoder::encode()} through my methods {@link
+ * decodeRequest} and {@link encodeResponse}.
+ *
+ * All my state is encapsulated in an {@link Auth_OpenID_OpenIDStore}.
+ *
+ * Example:
+ *
+ * <pre> $oserver = new Auth_OpenID_Server(Auth_OpenID_FileStore($data_path),
+ *                                   "http://example.com/op");
+ * $request = $oserver->decodeRequest();
+ * if (in_array($request->mode, array('checkid_immediate',
+ *                                    'checkid_setup'))) {
+ *     if ($app->isAuthorized($request->identity, $request->trust_root)) {
+ *         $response = $request->answer(true);
+ *     } else if ($request->immediate) {
+ *         $response = $request->answer(false);
+ *     } else {
+ *         $app->showDecidePage($request);
+ *         return;
+ *     }
+ * } else {
+ *     $response = $oserver->handleRequest($request);
+ * }
+ *
+ * $webresponse = $oserver->encode($response);</pre>
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_Server {
+    function Auth_OpenID_Server(&$store, $op_endpoint=null)
+    {
+        $this->store =& $store;
+        $this->signatory =& new Auth_OpenID_Signatory($this->store);
+        $this->encoder =& new Auth_OpenID_SigningEncoder($this->signatory);
+        $this->decoder =& new Auth_OpenID_Decoder($this);
+        $this->op_endpoint = $op_endpoint;
+        $this->negotiator =& Auth_OpenID_getDefaultNegotiator();
+    }
+
+    /**
+     * Handle a request.  Given an {@link Auth_OpenID_Request} object,
+     * call the appropriate {@link Auth_OpenID_Server} method to
+     * process the request and generate a response.
+     *
+     * @param Auth_OpenID_Request $request An {@link Auth_OpenID_Request}
+     * returned by {@link Auth_OpenID_Server::decodeRequest()}.
+     *
+     * @return Auth_OpenID_ServerResponse $response A response object
+     * capable of generating a user-agent reply.
+     */
+    function handleRequest($request)
+    {
+        if (method_exists($this, "openid_" . $request->mode)) {
+            $handler = array($this, "openid_" . $request->mode);
+            return call_user_func($handler, $request);
+        }
+        return null;
+    }
+
+    /**
+     * The callback for 'check_authentication' messages.
+     */
+    function openid_check_authentication(&$request)
+    {
+        return $request->answer($this->signatory);
+    }
+
+    /**
+     * The callback for 'associate' messages.
+     */
+    function openid_associate(&$request)
+    {
+        $assoc_type = $request->assoc_type;
+        $session_type = $request->session->session_type;
+        if ($this->negotiator->isAllowed($assoc_type, $session_type)) {
+            $assoc = $this->signatory->createAssociation(false,
+                                                         $assoc_type);
+            return $request->answer($assoc);
+        } else {
+            $message = sprintf('Association type %s is not supported with '.
+                               'session type %s', $assoc_type, $session_type);
+            list($preferred_assoc_type, $preferred_session_type) =
+                $this->negotiator->getAllowedType();
+            return $request->answerUnsupported($message,
+                                               $preferred_assoc_type,
+                                               $preferred_session_type);
+        }
+    }
+
+    /**
+     * Encodes as response in the appropriate format suitable for
+     * sending to the user agent.
+     */
+    function encodeResponse(&$response)
+    {
+        return $this->encoder->encode($response);
+    }
+
+    /**
+     * Decodes a query args array into the appropriate
+     * {@link Auth_OpenID_Request} object.
+     */
+    function decodeRequest($query=null)
+    {
+        if ($query === null) {
+            $query = Auth_OpenID::getQuery();
+        }
+
+        return $this->decoder->decode($query);
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/OpenID/ServerRequest.php b/inc/lib/Auth/OpenID/ServerRequest.php
new file mode 100644 (file)
index 0000000..33a8556
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+/**
+ * OpenID Server Request
+ *
+ * @see Auth_OpenID_Server
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Imports
+ */
+require_once "Auth/OpenID.php";
+
+/**
+ * Object that holds the state of a request to the OpenID server
+ *
+ * With accessor functions to get at the internal request data.
+ *
+ * @see Auth_OpenID_Server
+ * @package OpenID
+ */
+class Auth_OpenID_ServerRequest {
+    function Auth_OpenID_ServerRequest()
+    {
+        $this->mode = null;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/TrustRoot.php b/inc/lib/Auth/OpenID/TrustRoot.php
new file mode 100644 (file)
index 0000000..4919a60
--- /dev/null
@@ -0,0 +1,462 @@
+<?php
+/**
+ * Functions for dealing with OpenID trust roots
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+require_once 'Auth/OpenID/Discover.php';
+
+/**
+ * A regular expression that matches a domain ending in a top-level domains.
+ * Used in checking trust roots for sanity.
+ *
+ * @access private
+ */
+define('Auth_OpenID___TLDs',
+       '/\.(ac|ad|ae|aero|af|ag|ai|al|am|an|ao|aq|ar|arpa|as|asia' .
+       '|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|biz|bj|bm|bn|bo|br' .
+       '|bs|bt|bv|bw|by|bz|ca|cat|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co' .
+       '|com|coop|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg' .
+       '|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl' .
+       '|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie' .
+       '|il|im|in|info|int|io|iq|ir|is|it|je|jm|jo|jobs|jp|ke|kg|kh' .
+       '|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly' .
+       '|ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mo|mobi|mp|mq|mr|ms|mt' .
+       '|mu|museum|mv|mw|mx|my|mz|na|name|nc|ne|net|nf|ng|ni|nl|no' .
+       '|np|nr|nu|nz|om|org|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|pro|ps|pt' .
+       '|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl' .
+       '|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tel|tf|tg|th|tj|tk|tl|tm' .
+       '|tn|to|tp|tr|travel|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve' .
+       '|vg|vi|vn|vu|wf|ws|xn--0zwm56d|xn--11b5bs3a9aj6g' .
+       '|xn--80akhbyknj4f|xn--9t4b11yi5a|xn--deba0ad|xn--g6w251d' .
+       '|xn--hgbk6aj7f53bba|xn--hlcj6aya9esc7a|xn--jxalpdlp' .
+       '|xn--kgbechtv|xn--zckzah|ye|yt|yu|za|zm|zw)\.?$/');
+
+define('Auth_OpenID___HostSegmentRe',
+       "/^(?:[-a-zA-Z0-9!$&'\\(\\)\\*+,;=._~]|%[a-zA-Z0-9]{2})*$/");
+
+/**
+ * A wrapper for trust-root related functions
+ */
+class Auth_OpenID_TrustRoot {
+    /*
+     * Return a discovery URL for this realm.
+     *
+     * Return null if the realm could not be parsed or was not valid.
+     *
+     * @param return_to The relying party return URL of the OpenID
+     * authentication request
+     *
+     * @return The URL upon which relying party discovery should be
+     * run in order to verify the return_to URL
+     */
+    function buildDiscoveryURL($realm)
+    {
+        $parsed = Auth_OpenID_TrustRoot::_parse($realm);
+
+        if ($parsed === false) {
+            return false;
+        }
+
+        if ($parsed['wildcard']) {
+            // Use "www." in place of the star
+            if ($parsed['host'][0] != '.') {
+                return false;
+            }
+
+            $www_domain = 'www' . $parsed['host'];
+
+            return sprintf('%s://%s%s', $parsed['scheme'],
+                           $www_domain, $parsed['path']);
+        } else {
+            return $parsed['unparsed'];
+        }
+    }
+
+    /**
+     * Parse a URL into its trust_root parts.
+     *
+     * @static
+     *
+     * @access private
+     *
+     * @param string $trust_root The url to parse
+     *
+     * @return mixed $parsed Either an associative array of trust root
+     * parts or false if parsing failed.
+     */
+    function _parse($trust_root)
+    {
+        $trust_root = Auth_OpenID_urinorm($trust_root);
+        if ($trust_root === null) {
+            return false;
+        }
+
+        if (preg_match("/:\/\/[^:]+(:\d+){2,}(\/|$)/", $trust_root)) {
+            return false;
+        }
+
+        $parts = @parse_url($trust_root);
+        if ($parts === false) {
+            return false;
+        }
+
+        $required_parts = array('scheme', 'host');
+        $forbidden_parts = array('user', 'pass', 'fragment');
+        $keys = array_keys($parts);
+        if (array_intersect($keys, $required_parts) != $required_parts) {
+            return false;
+        }
+
+        if (array_intersect($keys, $forbidden_parts) != array()) {
+            return false;
+        }
+
+        if (!preg_match(Auth_OpenID___HostSegmentRe, $parts['host'])) {
+            return false;
+        }
+
+        $scheme = strtolower($parts['scheme']);
+        $allowed_schemes = array('http', 'https');
+        if (!in_array($scheme, $allowed_schemes)) {
+            return false;
+        }
+        $parts['scheme'] = $scheme;
+
+        $host = strtolower($parts['host']);
+        $hostparts = explode('*', $host);
+        switch (count($hostparts)) {
+        case 1:
+            $parts['wildcard'] = false;
+            break;
+        case 2:
+            if ($hostparts[0] ||
+                ($hostparts[1] && substr($hostparts[1], 0, 1) != '.')) {
+                return false;
+            }
+            $host = $hostparts[1];
+            $parts['wildcard'] = true;
+            break;
+        default:
+            return false;
+        }
+        if (strpos($host, ':') !== false) {
+            return false;
+        }
+
+        $parts['host'] = $host;
+
+        if (isset($parts['path'])) {
+            $path = strtolower($parts['path']);
+            if (substr($path, 0, 1) != '/') {
+                return false;
+            }
+        } else {
+            $path = '/';
+        }
+
+        $parts['path'] = $path;
+        if (!isset($parts['port'])) {
+            $parts['port'] = false;
+        }
+
+
+        $parts['unparsed'] = $trust_root;
+
+        return $parts;
+    }
+
+    /**
+     * Is this trust root sane?
+     *
+     * A trust root is sane if it is syntactically valid and it has a
+     * reasonable domain name. Specifically, the domain name must be
+     * more than one level below a standard TLD or more than two
+     * levels below a two-letter tld.
+     *
+     * For example, '*.com' is not a sane trust root, but '*.foo.com'
+     * is.  '*.co.uk' is not sane, but '*.bbc.co.uk' is.
+     *
+     * This check is not always correct, but it attempts to err on the
+     * side of marking sane trust roots insane instead of marking
+     * insane trust roots sane. For example, 'kink.fm' is marked as
+     * insane even though it "should" (for some meaning of should) be
+     * marked sane.
+     *
+     * This function should be used when creating OpenID servers to
+     * alert the users of the server when a consumer attempts to get
+     * the user to accept a suspicious trust root.
+     *
+     * @static
+     * @param string $trust_root The trust root to check
+     * @return bool $sanity Whether the trust root looks OK
+     */
+    function isSane($trust_root)
+    {
+        $parts = Auth_OpenID_TrustRoot::_parse($trust_root);
+        if ($parts === false) {
+            return false;
+        }
+
+        // Localhost is a special case
+        if ($parts['host'] == 'localhost') {
+            return true;
+        }
+        
+        $host_parts = explode('.', $parts['host']);
+        if ($parts['wildcard']) {
+            // Remove the empty string from the beginning of the array
+            array_shift($host_parts);
+        }
+
+        if ($host_parts && !$host_parts[count($host_parts) - 1]) {
+            array_pop($host_parts);
+        }
+
+        if (!$host_parts) {
+            return false;
+        }
+
+        // Don't allow adjacent dots
+        if (in_array('', $host_parts, true)) {
+            return false;
+        }
+
+        // Get the top-level domain of the host. If it is not a valid TLD,
+        // it's not sane.
+        preg_match(Auth_OpenID___TLDs, $parts['host'], $matches);
+        if (!$matches) {
+            return false;
+        }
+        $tld = $matches[1];
+
+        if (count($host_parts) == 1) {
+            return false;
+        }
+
+        if ($parts['wildcard']) {
+            // It's a 2-letter tld with a short second to last segment
+            // so there needs to be more than two segments specified
+            // (e.g. *.co.uk is insane)
+            $second_level = $host_parts[count($host_parts) - 2];
+            if (strlen($tld) == 2 && strlen($second_level) <= 3) {
+                return count($host_parts) > 2;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Does this URL match the given trust root?
+     *
+     * Return whether the URL falls under the given trust root. This
+     * does not check whether the trust root is sane. If the URL or
+     * trust root do not parse, this function will return false.
+     *
+     * @param string $trust_root The trust root to match against
+     *
+     * @param string $url The URL to check
+     *
+     * @return bool $matches Whether the URL matches against the
+     * trust root
+     */
+    function match($trust_root, $url)
+    {
+        $trust_root_parsed = Auth_OpenID_TrustRoot::_parse($trust_root);
+        $url_parsed = Auth_OpenID_TrustRoot::_parse($url);
+        if (!$trust_root_parsed || !$url_parsed) {
+            return false;
+        }
+
+        // Check hosts matching
+        if ($url_parsed['wildcard']) {
+            return false;
+        }
+        if ($trust_root_parsed['wildcard']) {
+            $host_tail = $trust_root_parsed['host'];
+            $host = $url_parsed['host'];
+            if ($host_tail &&
+                substr($host, -(strlen($host_tail))) != $host_tail &&
+                substr($host_tail, 1) != $host) {
+                return false;
+            }
+        } else {
+            if ($trust_root_parsed['host'] != $url_parsed['host']) {
+                return false;
+            }
+        }
+
+        // Check path and query matching
+        $base_path = $trust_root_parsed['path'];
+        $path = $url_parsed['path'];
+        if (!isset($trust_root_parsed['query'])) {
+            if ($base_path != $path) {
+                if (substr($path, 0, strlen($base_path)) != $base_path) {
+                    return false;
+                }
+                if (substr($base_path, strlen($base_path) - 1, 1) != '/' &&
+                    substr($path, strlen($base_path), 1) != '/') {
+                    return false;
+                }
+            }
+        } else {
+            $base_query = $trust_root_parsed['query'];
+            $query = @$url_parsed['query'];
+            $qplus = substr($query, 0, strlen($base_query) + 1);
+            $bqplus = $base_query . '&';
+            if ($base_path != $path ||
+                ($base_query != $query && $qplus != $bqplus)) {
+                return false;
+            }
+        }
+
+        // The port and scheme need to match exactly
+        return ($trust_root_parsed['scheme'] == $url_parsed['scheme'] &&
+                $url_parsed['port'] === $trust_root_parsed['port']);
+    }
+}
+
+/*
+ * If the endpoint is a relying party OpenID return_to endpoint,
+ * return the endpoint URL. Otherwise, return None.
+ *
+ * This function is intended to be used as a filter for the Yadis
+ * filtering interface.
+ *
+ * @see: C{L{openid.yadis.services}}
+ * @see: C{L{openid.yadis.filters}}
+ *
+ * @param endpoint: An XRDS BasicServiceEndpoint, as returned by
+ * performing Yadis dicovery.
+ *
+ * @returns: The endpoint URL or None if the endpoint is not a
+ * relying party endpoint.
+ */
+function filter_extractReturnURL(&$endpoint)
+{
+    if ($endpoint->matchTypes(array(Auth_OpenID_RP_RETURN_TO_URL_TYPE))) {
+        return $endpoint;
+    } else {
+        return null;
+    }
+}
+
+function &Auth_OpenID_extractReturnURL(&$endpoint_list)
+{
+    $result = array();
+
+    foreach ($endpoint_list as $endpoint) {
+        if (filter_extractReturnURL($endpoint)) {
+            $result[] = $endpoint;
+        }
+    }
+
+    return $result;
+}
+
+/*
+ * Is the return_to URL under one of the supplied allowed return_to
+ * URLs?
+ */
+function Auth_OpenID_returnToMatches($allowed_return_to_urls, $return_to)
+{
+    foreach ($allowed_return_to_urls as $allowed_return_to) {
+        // A return_to pattern works the same as a realm, except that
+        // it's not allowed to use a wildcard. We'll model this by
+        // parsing it as a realm, and not trying to match it if it has
+        // a wildcard.
+
+        $return_realm = Auth_OpenID_TrustRoot::_parse($allowed_return_to);
+        if (// Parses as a trust root
+            ($return_realm !== false) &&
+            // Does not have a wildcard
+            (!$return_realm['wildcard']) &&
+            // Matches the return_to that we passed in with it
+            (Auth_OpenID_TrustRoot::match($allowed_return_to, $return_to))) {
+            return true;
+        }
+    }
+
+    // No URL in the list matched
+    return false;
+}
+
+/*
+ * Given a relying party discovery URL return a list of return_to
+ * URLs.
+ */
+function Auth_OpenID_getAllowedReturnURLs($relying_party_url, &$fetcher,
+              $discover_function=null)
+{
+    if ($discover_function === null) {
+        $discover_function = array('Auth_Yadis_Yadis', 'discover');
+    }
+
+    $xrds_parse_cb = array('Auth_OpenID_ServiceEndpoint', 'fromXRDS');
+
+    list($rp_url_after_redirects, $endpoints) =
+        Auth_Yadis_getServiceEndpoints($relying_party_url, $xrds_parse_cb,
+                                       $discover_function, $fetcher);
+
+    if ($rp_url_after_redirects != $relying_party_url) {
+        // Verification caused a redirect
+        return false;
+    }
+
+    call_user_func_array($discover_function,
+                         array($relying_party_url, $fetcher));
+
+    $return_to_urls = array();
+    $matching_endpoints = Auth_OpenID_extractReturnURL($endpoints);
+
+    foreach ($matching_endpoints as $e) {
+        $return_to_urls[] = $e->server_url;
+    }
+
+    return $return_to_urls;
+}
+
+/*
+ * Verify that a return_to URL is valid for the given realm.
+ *
+ * This function builds a discovery URL, performs Yadis discovery on
+ * it, makes sure that the URL does not redirect, parses out the
+ * return_to URLs, and finally checks to see if the current return_to
+ * URL matches the return_to.
+ *
+ * @return true if the return_to URL is valid for the realm
+ */
+function Auth_OpenID_verifyReturnTo($realm_str, $return_to, &$fetcher,
+              $_vrfy='Auth_OpenID_getAllowedReturnURLs')
+{
+    $disco_url = Auth_OpenID_TrustRoot::buildDiscoveryURL($realm_str);
+
+    if ($disco_url === false) {
+        return false;
+    }
+
+    $allowable_urls = call_user_func_array($_vrfy,
+                           array($disco_url, &$fetcher));
+
+    // The realm_str could not be parsed.
+    if ($allowable_urls === false) {
+        return false;
+    }
+
+    if (Auth_OpenID_returnToMatches($allowable_urls, $return_to)) {
+        return true;
+    } else {
+        return false;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/OpenID/URINorm.php b/inc/lib/Auth/OpenID/URINorm.php
new file mode 100644 (file)
index 0000000..f821d83
--- /dev/null
@@ -0,0 +1,249 @@
+<?php
+
+/**
+ * URI normalization routines.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+require_once 'Auth/Yadis/Misc.php';
+
+// from appendix B of rfc 3986 (http://www.ietf.org/rfc/rfc3986.txt)
+function Auth_OpenID_getURIPattern()
+{
+    return '&^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?&';
+}
+
+function Auth_OpenID_getAuthorityPattern()
+{
+    return '/^([^@]*@)?([^:]*)(:.*)?/';
+}
+
+function Auth_OpenID_getEncodedPattern()
+{
+    return '/%([0-9A-Fa-f]{2})/';
+}
+
+# gen-delims  = ":" / "/" / "?" / "#" / "[" / "]" / "@"
+#
+# sub-delims  = "!" / "$" / "&" / "'" / "(" / ")"
+#                  / "*" / "+" / "," / ";" / "="
+#
+# unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
+function Auth_OpenID_getURLIllegalCharRE()
+{
+    return "/([^-A-Za-z0-9:\/\?#\[\]@\!\$&'\(\)\*\+,;=\._~\%])/";
+}
+
+function Auth_OpenID_getUnreserved()
+{
+    $_unreserved = array();
+    for ($i = 0; $i < 256; $i++) {
+        $_unreserved[$i] = false;
+    }
+
+    for ($i = ord('A'); $i <= ord('Z'); $i++) {
+        $_unreserved[$i] = true;
+    }
+
+    for ($i = ord('0'); $i <= ord('9'); $i++) {
+        $_unreserved[$i] = true;
+    }
+
+    for ($i = ord('a'); $i <= ord('z'); $i++) {
+        $_unreserved[$i] = true;
+    }
+
+    $_unreserved[ord('-')] = true;
+    $_unreserved[ord('.')] = true;
+    $_unreserved[ord('_')] = true;
+    $_unreserved[ord('~')] = true;
+
+    return $_unreserved;
+}
+
+function Auth_OpenID_getEscapeRE()
+{
+    $parts = array();
+    foreach (array_merge(Auth_Yadis_getUCSChars(),
+                         Auth_Yadis_getIPrivateChars()) as $pair) {
+        list($m, $n) = $pair;
+        $parts[] = sprintf("%s-%s", chr($m), chr($n));
+    }
+
+    return sprintf('[%s]', implode('', $parts));
+}
+
+function Auth_OpenID_pct_encoded_replace_unreserved($mo)
+{
+    $_unreserved = Auth_OpenID_getUnreserved();
+
+    $i = intval($mo[1], 16);
+    if ($_unreserved[$i]) {
+        return chr($i);
+    } else {
+        return strtoupper($mo[0]);
+    }
+
+    return $mo[0];
+}
+
+function Auth_OpenID_pct_encoded_replace($mo)
+{
+    return chr(intval($mo[1], 16));
+}
+
+function Auth_OpenID_remove_dot_segments($path)
+{
+    $result_segments = array();
+
+    while ($path) {
+        if (Auth_Yadis_startswith($path, '../')) {
+            $path = substr($path, 3);
+        } else if (Auth_Yadis_startswith($path, './')) {
+            $path = substr($path, 2);
+        } else if (Auth_Yadis_startswith($path, '/./')) {
+            $path = substr($path, 2);
+        } else if ($path == '/.') {
+            $path = '/';
+        } else if (Auth_Yadis_startswith($path, '/../')) {
+            $path = substr($path, 3);
+            if ($result_segments) {
+                array_pop($result_segments);
+            }
+        } else if ($path == '/..') {
+            $path = '/';
+            if ($result_segments) {
+                array_pop($result_segments);
+            }
+        } else if (($path == '..') ||
+                   ($path == '.')) {
+            $path = '';
+        } else {
+            $i = 0;
+            if ($path[0] == '/') {
+                $i = 1;
+            }
+            $i = strpos($path, '/', $i);
+            if ($i === false) {
+                $i = strlen($path);
+            }
+            $result_segments[] = substr($path, 0, $i);
+            $path = substr($path, $i);
+        }
+    }
+
+    return implode('', $result_segments);
+}
+
+function Auth_OpenID_urinorm($uri)
+{
+    $uri_matches = array();
+    preg_match(Auth_OpenID_getURIPattern(), $uri, $uri_matches);
+
+    if (count($uri_matches) < 9) {
+        for ($i = count($uri_matches); $i <= 9; $i++) {
+            $uri_matches[] = '';
+        }
+    }
+
+    $illegal_matches = array();
+    preg_match(Auth_OpenID_getURLIllegalCharRE(),
+               $uri, $illegal_matches);
+    if ($illegal_matches) {
+        return null;
+    }
+
+    $scheme = $uri_matches[2];
+    if ($scheme) {
+        $scheme = strtolower($scheme);
+    }
+
+    $scheme = $uri_matches[2];
+    if ($scheme === '') {
+        // No scheme specified
+        return null;
+    }
+
+    $scheme = strtolower($scheme);
+    if (!in_array($scheme, array('http', 'https'))) {
+        // Not an absolute HTTP or HTTPS URI
+        return null;
+    }
+
+    $authority = $uri_matches[4];
+    if ($authority === '') {
+        // Not an absolute URI
+        return null;
+    }
+
+    $authority_matches = array();
+    preg_match(Auth_OpenID_getAuthorityPattern(),
+               $authority, $authority_matches);
+    if (count($authority_matches) === 0) {
+        // URI does not have a valid authority
+        return null;
+    }
+
+    if (count($authority_matches) < 4) {
+        for ($i = count($authority_matches); $i <= 4; $i++) {
+            $authority_matches[] = '';
+        }
+    }
+
+    list($_whole, $userinfo, $host, $port) = $authority_matches;
+
+    if ($userinfo === null) {
+        $userinfo = '';
+    }
+
+    if (strpos($host, '%') !== -1) {
+        $host = strtolower($host);
+        $host = preg_replace_callback(
+                  Auth_OpenID_getEncodedPattern(),
+                  'Auth_OpenID_pct_encoded_replace', $host);
+        // NO IDNA.
+        // $host = unicode($host, 'utf-8').encode('idna');
+    } else {
+        $host = strtolower($host);
+    }
+
+    if ($port) {
+        if (($port == ':') ||
+            ($scheme == 'http' && $port == ':80') ||
+            ($scheme == 'https' && $port == ':443')) {
+            $port = '';
+        }
+    } else {
+        $port = '';
+    }
+
+    $authority = $userinfo . $host . $port;
+
+    $path = $uri_matches[5];
+    $path = preg_replace_callback(
+               Auth_OpenID_getEncodedPattern(),
+               'Auth_OpenID_pct_encoded_replace_unreserved', $path);
+
+    $path = Auth_OpenID_remove_dot_segments($path);
+    if (!$path) {
+        $path = '/';
+    }
+
+    $query = $uri_matches[6];
+    if ($query === null) {
+        $query = '';
+    }
+
+    $fragment = $uri_matches[8];
+    if ($fragment === null) {
+        $fragment = '';
+    }
+
+    return $scheme . '://' . $authority . $path . $query . $fragment;
+}
+
+?>
diff --git a/inc/lib/Auth/Yadis/HTTPFetcher.php b/inc/lib/Auth/Yadis/HTTPFetcher.php
new file mode 100644 (file)
index 0000000..963b9a4
--- /dev/null
@@ -0,0 +1,147 @@
+<?php
+
+/**
+ * This module contains the HTTP fetcher interface
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Require logging functionality
+ */
+require_once "Auth/OpenID.php";
+
+define('Auth_OpenID_FETCHER_MAX_RESPONSE_KB', 1024);
+define('Auth_OpenID_USER_AGENT', 
+       'php-openid/'.Auth_OpenID_VERSION.' (php/'.phpversion().')');
+
+class Auth_Yadis_HTTPResponse {
+    function Auth_Yadis_HTTPResponse($final_url = null, $status = null,
+                                         $headers = null, $body = null)
+    {
+        $this->final_url = $final_url;
+        $this->status = $status;
+        $this->headers = $headers;
+        $this->body = $body;
+    }
+}
+
+/**
+ * This class is the interface for HTTP fetchers the Yadis library
+ * uses.  This interface is only important if you need to write a new
+ * fetcher for some reason.
+ *
+ * @access private
+ * @package OpenID
+ */
+class Auth_Yadis_HTTPFetcher {
+
+    var $timeout = 20; // timeout in seconds.
+
+    /**
+     * Return whether a URL can be fetched.  Returns false if the URL
+     * scheme is not allowed or is not supported by this fetcher
+     * implementation; returns true otherwise.
+     *
+     * @return bool
+     */
+    function canFetchURL($url)
+    {
+        if ($this->isHTTPS($url) && !$this->supportsSSL()) {
+            Auth_OpenID::log("HTTPS URL unsupported fetching %s",
+                             $url);
+            return false;
+        }
+
+        if (!$this->allowedURL($url)) {
+            Auth_OpenID::log("URL fetching not allowed for '%s'",
+                             $url);
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Return whether a URL should be allowed. Override this method to
+     * conform to your local policy.
+     *
+     * By default, will attempt to fetch any http or https URL.
+     */
+    function allowedURL($url)
+    {
+        return $this->URLHasAllowedScheme($url);
+    }
+
+    /**
+     * Does this fetcher implementation (and runtime) support fetching
+     * HTTPS URLs?  May inspect the runtime environment.
+     *
+     * @return bool $support True if this fetcher supports HTTPS
+     * fetching; false if not.
+     */
+    function supportsSSL()
+    {
+        trigger_error("not implemented", E_USER_ERROR);
+    }
+
+    /**
+     * Is this an https URL?
+     *
+     * @access private
+     */
+    function isHTTPS($url)
+    {
+        return (bool)preg_match('/^https:\/\//i', $url);
+    }
+
+    /**
+     * Is this an http or https URL?
+     *
+     * @access private
+     */
+    function URLHasAllowedScheme($url)
+    {
+        return (bool)preg_match('/^https?:\/\//i', $url);
+    }
+
+    /**
+     * @access private
+     */
+    function _findRedirect($headers)
+    {
+        foreach ($headers as $line) {
+            if (strpos(strtolower($line), "location: ") === 0) {
+                $parts = explode(" ", $line, 2);
+                return $parts[1];
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Fetches the specified URL using optional extra headers and
+     * returns the server's response.
+     *
+     * @param string $url The URL to be fetched.
+     * @param array $extra_headers An array of header strings
+     * (e.g. "Accept: text/html").
+     * @return mixed $result An array of ($code, $url, $headers,
+     * $body) if the URL could be fetched; null if the URL does not
+     * pass the URLHasAllowedScheme check or if the server's response
+     * is malformed.
+     */
+    function get($url, $headers = null)
+    {
+        trigger_error("not implemented", E_USER_ERROR);
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/Yadis/Manager.php b/inc/lib/Auth/Yadis/Manager.php
new file mode 100644 (file)
index 0000000..d50cf7a
--- /dev/null
@@ -0,0 +1,529 @@
+<?php
+
+/**
+ * Yadis service manager to be used during yadis-driven authentication
+ * attempts.
+ *
+ * @package OpenID
+ */
+
+/**
+ * The base session class used by the Auth_Yadis_Manager.  This
+ * class wraps the default PHP session machinery and should be
+ * subclassed if your application doesn't use PHP sessioning.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_PHPSession {
+    /**
+     * Set a session key/value pair.
+     *
+     * @param string $name The name of the session key to add.
+     * @param string $value The value to add to the session.
+     */
+    function set($name, $value)
+    {
+        $_SESSION[$name] = $value;
+    }
+
+    /**
+     * Get a key's value from the session.
+     *
+     * @param string $name The name of the key to retrieve.
+     * @param string $default The optional value to return if the key
+     * is not found in the session.
+     * @return string $result The key's value in the session or
+     * $default if it isn't found.
+     */
+    function get($name, $default=null)
+    {
+        if (array_key_exists($name, $_SESSION)) {
+            return $_SESSION[$name];
+        } else {
+            return $default;
+        }
+    }
+
+    /**
+     * Remove a key/value pair from the session.
+     *
+     * @param string $name The name of the key to remove.
+     */
+    function del($name)
+    {
+        unset($_SESSION[$name]);
+    }
+
+    /**
+     * Return the contents of the session in array form.
+     */
+    function contents()
+    {
+        return $_SESSION;
+    }
+}
+
+/**
+ * A session helper class designed to translate between arrays and
+ * objects.  Note that the class used must have a constructor that
+ * takes no parameters.  This is not a general solution, but it works
+ * for dumb objects that just need to have attributes set.  The idea
+ * is that you'll subclass this and override $this->check($data) ->
+ * bool to implement your own session data validation.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_SessionLoader {
+    /**
+     * Override this.
+     *
+     * @access private
+     */
+    function check($data)
+    {
+        return true;
+    }
+
+    /**
+     * Given a session data value (an array), this creates an object
+     * (returned by $this->newObject()) whose attributes and values
+     * are those in $data.  Returns null if $data lacks keys found in
+     * $this->requiredKeys().  Returns null if $this->check($data)
+     * evaluates to false.  Returns null if $this->newObject()
+     * evaluates to false.
+     *
+     * @access private
+     */
+    function fromSession($data)
+    {
+        if (!$data) {
+            return null;
+        }
+
+        $required = $this->requiredKeys();
+
+        foreach ($required as $k) {
+            if (!array_key_exists($k, $data)) {
+                return null;
+            }
+        }
+
+        if (!$this->check($data)) {
+            return null;
+        }
+
+        $data = array_merge($data, $this->prepareForLoad($data));
+        $obj = $this->newObject($data);
+
+        if (!$obj) {
+            return null;
+        }
+
+        foreach ($required as $k) {
+            $obj->$k = $data[$k];
+        }
+
+        return $obj;
+    }
+
+    /**
+     * Prepares the data array by making any necessary changes.
+     * Returns an array whose keys and values will be used to update
+     * the original data array before calling $this->newObject($data).
+     *
+     * @access private
+     */
+    function prepareForLoad($data)
+    {
+        return array();
+    }
+
+    /**
+     * Returns a new instance of this loader's class, using the
+     * session data to construct it if necessary.  The object need
+     * only be created; $this->fromSession() will take care of setting
+     * the object's attributes.
+     *
+     * @access private
+     */
+    function newObject($data)
+    {
+        return null;
+    }
+
+    /**
+     * Returns an array of keys and values built from the attributes
+     * of $obj.  If $this->prepareForSave($obj) returns an array, its keys
+     * and values are used to update the $data array of attributes
+     * from $obj.
+     *
+     * @access private
+     */
+    function toSession($obj)
+    {
+        $data = array();
+        foreach ($obj as $k => $v) {
+            $data[$k] = $v;
+        }
+
+        $extra = $this->prepareForSave($obj);
+
+        if ($extra && is_array($extra)) {
+            foreach ($extra as $k => $v) {
+                $data[$k] = $v;
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Override this.
+     *
+     * @access private
+     */
+    function prepareForSave($obj)
+    {
+        return array();
+    }
+}
+
+/**
+ * A concrete loader implementation for Auth_OpenID_ServiceEndpoints.
+ *
+ * @package OpenID
+ */
+class Auth_OpenID_ServiceEndpointLoader extends Auth_Yadis_SessionLoader {
+    function newObject($data)
+    {
+        return new Auth_OpenID_ServiceEndpoint();
+    }
+
+    function requiredKeys()
+    {
+        $obj = new Auth_OpenID_ServiceEndpoint();
+        $data = array();
+        foreach ($obj as $k => $v) {
+            $data[] = $k;
+        }
+        return $data;
+    }
+
+    function check($data)
+    {
+        return is_array($data['type_uris']);
+    }
+}
+
+/**
+ * A concrete loader implementation for Auth_Yadis_Managers.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_ManagerLoader extends Auth_Yadis_SessionLoader {
+    function requiredKeys()
+    {
+        return array('starting_url',
+                     'yadis_url',
+                     'services',
+                     'session_key',
+                     '_current',
+                     'stale');
+    }
+
+    function newObject($data)
+    {
+        return new Auth_Yadis_Manager($data['starting_url'],
+                                          $data['yadis_url'],
+                                          $data['services'],
+                                          $data['session_key']);
+    }
+
+    function check($data)
+    {
+        return is_array($data['services']);
+    }
+
+    function prepareForLoad($data)
+    {
+        $loader = new Auth_OpenID_ServiceEndpointLoader();
+        $services = array();
+        foreach ($data['services'] as $s) {
+            $services[] = $loader->fromSession($s);
+        }
+        return array('services' => $services);
+    }
+
+    function prepareForSave($obj)
+    {
+        $loader = new Auth_OpenID_ServiceEndpointLoader();
+        $services = array();
+        foreach ($obj->services as $s) {
+            $services[] = $loader->toSession($s);
+        }
+        return array('services' => $services);
+    }
+}
+
+/**
+ * The Yadis service manager which stores state in a session and
+ * iterates over <Service> elements in a Yadis XRDS document and lets
+ * a caller attempt to use each one.  This is used by the Yadis
+ * library internally.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_Manager {
+
+    /**
+     * Intialize a new yadis service manager.
+     *
+     * @access private
+     */
+    function Auth_Yadis_Manager($starting_url, $yadis_url,
+                                    $services, $session_key)
+    {
+        // The URL that was used to initiate the Yadis protocol
+        $this->starting_url = $starting_url;
+
+        // The URL after following redirects (the identifier)
+        $this->yadis_url = $yadis_url;
+
+        // List of service elements
+        $this->services = $services;
+
+        $this->session_key = $session_key;
+
+        // Reference to the current service object
+        $this->_current = null;
+
+        // Stale flag for cleanup if PHP lib has trouble.
+        $this->stale = false;
+    }
+
+    /**
+     * @access private
+     */
+    function length()
+    {
+        // How many untried services remain?
+        return count($this->services);
+    }
+
+    /**
+     * Return the next service
+     *
+     * $this->current() will continue to return that service until the
+     * next call to this method.
+     */
+    function nextService()
+    {
+
+        if ($this->services) {
+            $this->_current = array_shift($this->services);
+        } else {
+            $this->_current = null;
+        }
+
+        return $this->_current;
+    }
+
+    /**
+     * @access private
+     */
+    function current()
+    {
+        // Return the current service.
+        // Returns None if there are no services left.
+        return $this->_current;
+    }
+
+    /**
+     * @access private
+     */
+    function forURL($url)
+    {
+        return in_array($url, array($this->starting_url, $this->yadis_url));
+    }
+
+    /**
+     * @access private
+     */
+    function started()
+    {
+        // Has the first service been returned?
+        return $this->_current !== null;
+    }
+}
+
+/**
+ * State management for discovery.
+ *
+ * High-level usage pattern is to call .getNextService(discover) in
+ * order to find the next available service for this user for this
+ * session. Once a request completes, call .cleanup() to clean up the
+ * session state.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_Discovery {
+
+    /**
+     * @access private
+     */
+    var $DEFAULT_SUFFIX = 'auth';
+
+    /**
+     * @access private
+     */
+    var $PREFIX = '_yadis_services_';
+
+    /**
+     * Initialize a discovery object.
+     *
+     * @param Auth_Yadis_PHPSession $session An object which
+     * implements the Auth_Yadis_PHPSession API.
+     * @param string $url The URL on which to attempt discovery.
+     * @param string $session_key_suffix The optional session key
+     * suffix override.
+     */
+    function Auth_Yadis_Discovery(&$session, $url,
+                                      $session_key_suffix = null)
+    {
+        /// Initialize a discovery object
+        $this->session =& $session;
+        $this->url = $url;
+        if ($session_key_suffix === null) {
+            $session_key_suffix = $this->DEFAULT_SUFFIX;
+        }
+
+        $this->session_key_suffix = $session_key_suffix;
+        $this->session_key = $this->PREFIX . $this->session_key_suffix;
+    }
+
+    /**
+     * Return the next authentication service for the pair of
+     * user_input and session. This function handles fallback.
+     */
+    function getNextService($discover_cb, &$fetcher)
+    {
+        $manager = $this->getManager();
+        if (!$manager || (!$manager->services)) {
+            $this->destroyManager();
+
+            list($yadis_url, $services) = call_user_func($discover_cb,
+                                                         $this->url,
+                                                         $fetcher);
+
+            $manager = $this->createManager($services, $yadis_url);
+        }
+
+        if ($manager) {
+            $loader = new Auth_Yadis_ManagerLoader();
+            $service = $manager->nextService();
+            $this->session->set($this->session_key,
+                                serialize($loader->toSession($manager)));
+        } else {
+            $service = null;
+        }
+
+        return $service;
+    }
+
+    /**
+     * Clean up Yadis-related services in the session and return the
+     * most-recently-attempted service from the manager, if one
+     * exists.
+     *
+     * @param $force True if the manager should be deleted regardless
+     * of whether it's a manager for $this->url.
+     */
+    function cleanup($force=false)
+    {
+        $manager = $this->getManager($force);
+        if ($manager) {
+            $service = $manager->current();
+            $this->destroyManager($force);
+        } else {
+            $service = null;
+        }
+
+        return $service;
+    }
+
+    /**
+     * @access private
+     */
+    function getSessionKey()
+    {
+        // Get the session key for this starting URL and suffix
+        return $this->PREFIX . $this->session_key_suffix;
+    }
+
+    /**
+     * @access private
+     *
+     * @param $force True if the manager should be returned regardless
+     * of whether it's a manager for $this->url.
+     */
+    function &getManager($force=false)
+    {
+        // Extract the YadisServiceManager for this object's URL and
+        // suffix from the session.
+
+        $manager_str = $this->session->get($this->getSessionKey());
+        $manager = null;
+
+        if ($manager_str !== null) {
+            $loader = new Auth_Yadis_ManagerLoader();
+            $manager = $loader->fromSession(unserialize($manager_str));
+        }
+
+        if ($manager && ($manager->forURL($this->url) || $force)) {
+            return $manager;
+        } else {
+            $unused = null;
+            return $unused;
+        }
+    }
+
+    /**
+     * @access private
+     */
+    function &createManager($services, $yadis_url = null)
+    {
+        $key = $this->getSessionKey();
+        if ($this->getManager()) {
+            return $this->getManager();
+        }
+
+        if ($services) {
+            $loader = new Auth_Yadis_ManagerLoader();
+            $manager = new Auth_Yadis_Manager($this->url, $yadis_url,
+                                              $services, $key);
+            $this->session->set($this->session_key,
+                                serialize($loader->toSession($manager)));
+            return $manager;
+        } else {
+            // Oh, PHP.
+            $unused = null;
+            return $unused;
+        }
+    }
+
+    /**
+     * @access private
+     *
+     * @param $force True if the manager should be deleted regardless
+     * of whether it's a manager for $this->url.
+     */
+    function destroyManager($force=false)
+    {
+        if ($this->getManager($force) !== null) {
+            $key = $this->getSessionKey();
+            $this->session->del($key);
+        }
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/Yadis/Misc.php b/inc/lib/Auth/Yadis/Misc.php
new file mode 100644 (file)
index 0000000..1134a4f
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * Miscellaneous utility values and functions for OpenID and Yadis.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+function Auth_Yadis_getUCSChars()
+{
+    return array(
+                 array(0xA0, 0xD7FF),
+                 array(0xF900, 0xFDCF),
+                 array(0xFDF0, 0xFFEF),
+                 array(0x10000, 0x1FFFD),
+                 array(0x20000, 0x2FFFD),
+                 array(0x30000, 0x3FFFD),
+                 array(0x40000, 0x4FFFD),
+                 array(0x50000, 0x5FFFD),
+                 array(0x60000, 0x6FFFD),
+                 array(0x70000, 0x7FFFD),
+                 array(0x80000, 0x8FFFD),
+                 array(0x90000, 0x9FFFD),
+                 array(0xA0000, 0xAFFFD),
+                 array(0xB0000, 0xBFFFD),
+                 array(0xC0000, 0xCFFFD),
+                 array(0xD0000, 0xDFFFD),
+                 array(0xE1000, 0xEFFFD)
+                 );
+}
+
+function Auth_Yadis_getIPrivateChars()
+{
+    return array(
+                 array(0xE000, 0xF8FF),
+                 array(0xF0000, 0xFFFFD),
+                 array(0x100000, 0x10FFFD)
+                 );
+}
+
+function Auth_Yadis_pct_escape_unicode($char_match)
+{
+    $c = $char_match[0];
+    $result = "";
+    for ($i = 0; $i < strlen($c); $i++) {
+        $result .= "%".sprintf("%X", ord($c[$i]));
+    }
+    return $result;
+}
+
+function Auth_Yadis_startswith($s, $stuff)
+{
+    return strpos($s, $stuff) === 0;
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/Yadis/ParanoidHTTPFetcher.php b/inc/lib/Auth/Yadis/ParanoidHTTPFetcher.php
new file mode 100644 (file)
index 0000000..6a41826
--- /dev/null
@@ -0,0 +1,226 @@
+<?php
+
+/**
+ * This module contains the CURL-based HTTP fetcher implementation.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Interface import
+ */
+require_once "Auth/Yadis/HTTPFetcher.php";
+
+require_once "Auth/OpenID.php";
+
+/**
+ * A paranoid {@link Auth_Yadis_HTTPFetcher} class which uses CURL
+ * for fetching.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_ParanoidHTTPFetcher extends Auth_Yadis_HTTPFetcher {
+    function Auth_Yadis_ParanoidHTTPFetcher()
+    {
+        $this->reset();
+    }
+
+    function reset()
+    {
+        $this->headers = array();
+        $this->data = "";
+    }
+
+    /**
+     * @access private
+     */
+    function _writeHeader($ch, $header)
+    {
+        array_push($this->headers, rtrim($header));
+        return strlen($header);
+    }
+
+    /**
+     * @access private
+     */
+    function _writeData($ch, $data)
+    {
+        if (strlen($this->data) > 1024*Auth_OpenID_FETCHER_MAX_RESPONSE_KB) {
+            return 0;
+        } else {
+            $this->data .= $data;
+            return strlen($data);
+        }
+    }
+
+    /**
+     * Does this fetcher support SSL URLs?
+     */
+    function supportsSSL()
+    {
+        $v = curl_version();
+        if(is_array($v)) {
+            return in_array('https', $v['protocols']);
+        } elseif (is_string($v)) {
+            return preg_match('/OpenSSL/i', $v);
+        } else {
+            return 0;
+        }
+    }
+
+    function get($url, $extra_headers = null)
+    {
+        if (!$this->canFetchURL($url)) {
+            return null;
+        }
+
+        $stop = time() + $this->timeout;
+        $off = $this->timeout;
+
+        $redir = true;
+
+        while ($redir && ($off > 0)) {
+            $this->reset();
+
+            $c = curl_init();
+
+            if ($c === false) {
+                Auth_OpenID::log(
+                    "curl_init returned false; could not " .
+                    "initialize for URL '%s'", $url);
+                return null;
+            }
+
+            if (defined('CURLOPT_NOSIGNAL')) {
+                curl_setopt($c, CURLOPT_NOSIGNAL, true);
+            }
+
+            if (!$this->allowedURL($url)) {
+                Auth_OpenID::log("Fetching URL not allowed: %s",
+                                 $url);
+                return null;
+            }
+
+            curl_setopt($c, CURLOPT_WRITEFUNCTION,
+                        array(&$this, "_writeData"));
+            curl_setopt($c, CURLOPT_HEADERFUNCTION,
+                        array(&$this, "_writeHeader"));
+
+            if ($extra_headers) {
+                curl_setopt($c, CURLOPT_HTTPHEADER, $extra_headers);
+            }
+
+            $cv = curl_version();
+            if(is_array($cv)) {
+              $curl_user_agent = 'curl/'.$cv['version'];
+            } else {
+              $curl_user_agent = $cv;
+            }
+            curl_setopt($c, CURLOPT_USERAGENT,
+                        Auth_OpenID_USER_AGENT.' '.$curl_user_agent);
+            curl_setopt($c, CURLOPT_TIMEOUT, $off);
+            curl_setopt($c, CURLOPT_URL, $url);
+
+            curl_exec($c);
+
+            $code = curl_getinfo($c, CURLINFO_HTTP_CODE);
+            $body = $this->data;
+            $headers = $this->headers;
+
+            if (!$code) {
+                Auth_OpenID::log("Got no response code when fetching %s", $url);
+                Auth_OpenID::log("CURL error (%s): %s",
+                                 curl_errno($c), curl_error($c));
+                return null;
+            }
+
+            if (in_array($code, array(301, 302, 303, 307))) {
+                $url = $this->_findRedirect($headers);
+                $redir = true;
+            } else {
+                $redir = false;
+                curl_close($c);
+
+                $new_headers = array();
+
+                foreach ($headers as $header) {
+                    if (strpos($header, ': ')) {
+                        list($name, $value) = explode(': ', $header, 2);
+                        $new_headers[$name] = $value;
+                    }
+                }
+
+                Auth_OpenID::log(
+                    "Successfully fetched '%s': GET response code %s",
+                    $url, $code);
+
+                return new Auth_Yadis_HTTPResponse($url, $code,
+                                                    $new_headers, $body);
+            }
+
+            $off = $stop - time();
+        }
+
+        return null;
+    }
+
+    function post($url, $body, $extra_headers = null)
+    {
+        if (!$this->canFetchURL($url)) {
+            return null;
+        }
+
+        $this->reset();
+
+        $c = curl_init();
+
+        if (defined('CURLOPT_NOSIGNAL')) {
+            curl_setopt($c, CURLOPT_NOSIGNAL, true);
+        }
+
+        curl_setopt($c, CURLOPT_POST, true);
+        curl_setopt($c, CURLOPT_POSTFIELDS, $body);
+        curl_setopt($c, CURLOPT_TIMEOUT, $this->timeout);
+        curl_setopt($c, CURLOPT_URL, $url);
+        curl_setopt($c, CURLOPT_WRITEFUNCTION,
+                    array(&$this, "_writeData"));
+
+        curl_exec($c);
+
+        $code = curl_getinfo($c, CURLINFO_HTTP_CODE);
+
+        if (!$code) {
+            Auth_OpenID::log("Got no response code when fetching %s", $url);
+            return null;
+        }
+
+        $body = $this->data;
+
+        curl_close($c);
+
+        $new_headers = $extra_headers;
+
+        foreach ($this->headers as $header) {
+            if (strpos($header, ': ')) {
+                list($name, $value) = explode(': ', $header, 2);
+                $new_headers[$name] = $value;
+            }
+
+        }
+
+        Auth_OpenID::log("Successfully fetched '%s': POST response code %s",
+                         $url, $code);
+
+        return new Auth_Yadis_HTTPResponse($url, $code,
+                                           $new_headers, $body);
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/Yadis/ParseHTML.php b/inc/lib/Auth/Yadis/ParseHTML.php
new file mode 100644 (file)
index 0000000..297ccbd
--- /dev/null
@@ -0,0 +1,259 @@
+<?php
+
+/**
+ * This is the HTML pseudo-parser for the Yadis library.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * This class is responsible for scanning an HTML string to find META
+ * tags and their attributes.  This is used by the Yadis discovery
+ * process.  This class must be instantiated to be used.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_ParseHTML {
+
+    /**
+     * @access private
+     */
+    var $_re_flags = "si";
+
+    /**
+     * @access private
+     */
+    var $_removed_re =
+           "<!--.*?-->|<!\[CDATA\[.*?\]\]>|<script\b(?!:)[^>]*>.*?<\/script>";
+
+    /**
+     * @access private
+     */
+    var $_tag_expr = "<%s%s(?:\s.*?)?%s>";
+
+    /**
+     * @access private
+     */
+    var $_attr_find = '\b([-\w]+)=(".*?"|\'.*?\'|.+?)[\/\s>]';
+
+    function Auth_Yadis_ParseHTML()
+    {
+        $this->_attr_find = sprintf("/%s/%s",
+                                    $this->_attr_find,
+                                    $this->_re_flags);
+
+        $this->_removed_re = sprintf("/%s/%s",
+                                     $this->_removed_re,
+                                     $this->_re_flags);
+
+        $this->_entity_replacements = array(
+                                            'amp' => '&',
+                                            'lt' => '<',
+                                            'gt' => '>',
+                                            'quot' => '"'
+                                            );
+
+        $this->_ent_replace =
+            sprintf("&(%s);", implode("|",
+                                      $this->_entity_replacements));
+    }
+
+    /**
+     * Replace HTML entities (amp, lt, gt, and quot) as well as
+     * numeric entities (e.g. #x9f;) with their actual values and
+     * return the new string.
+     *
+     * @access private
+     * @param string $str The string in which to look for entities
+     * @return string $new_str The new string entities decoded
+     */
+    function replaceEntities($str)
+    {
+        foreach ($this->_entity_replacements as $old => $new) {
+            $str = preg_replace(sprintf("/&%s;/", $old), $new, $str);
+        }
+
+        // Replace numeric entities because html_entity_decode doesn't
+        // do it for us.
+        $str = preg_replace('~&#x([0-9a-f]+);~ei', 'chr(hexdec("\\1"))', $str);
+        $str = preg_replace('~&#([0-9]+);~e', 'chr(\\1)', $str);
+
+        return $str;
+    }
+
+    /**
+     * Strip single and double quotes off of a string, if they are
+     * present.
+     *
+     * @access private
+     * @param string $str The original string
+     * @return string $new_str The new string with leading and
+     * trailing quotes removed
+     */
+    function removeQuotes($str)
+    {
+        $matches = array();
+        $double = '/^"(.*)"$/';
+        $single = "/^\'(.*)\'$/";
+
+        if (preg_match($double, $str, $matches)) {
+            return $matches[1];
+        } else if (preg_match($single, $str, $matches)) {
+            return $matches[1];
+        } else {
+            return $str;
+        }
+    }
+
+    /**
+     * Create a regular expression that will match an opening 
+     * or closing tag from a set of names.
+     *
+     * @access private
+     * @param mixed $tag_names Tag names to match
+     * @param mixed $close false/0 = no, true/1 = yes, other = maybe
+     * @param mixed $self_close false/0 = no, true/1 = yes, other = maybe
+     * @return string $regex A regular expression string to be used
+     * in, say, preg_match.
+     */
+    function tagPattern($tag_names, $close, $self_close)
+    {
+        if (is_array($tag_names)) {
+            $tag_names = '(?:'.implode('|',$tag_names).')';
+        }
+        if ($close) {
+            $close = '\/' . (($close == 1)? '' : '?');
+        } else {
+            $close = '';
+        }
+        if ($self_close) {
+            $self_close = '(?:\/\s*)' . (($self_close == 1)? '' : '?');
+        } else {
+            $self_close = '';
+        }
+        $expr = sprintf($this->_tag_expr, $close, $tag_names, $self_close);
+
+        return sprintf("/%s/%s", $expr, $this->_re_flags);
+    }
+
+    /**
+     * Given an HTML document string, this finds all the META tags in
+     * the document, provided they are found in the
+     * <HTML><HEAD>...</HEAD> section of the document.  The <HTML> tag
+     * may be missing.
+     *
+     * @access private
+     * @param string $html_string An HTMl document string
+     * @return array $tag_list Array of tags; each tag is an array of
+     * attribute -> value.
+     */
+    function getMetaTags($html_string)
+    {
+        $html_string = preg_replace($this->_removed_re,
+                                    "",
+                                    $html_string);
+
+        $key_tags = array($this->tagPattern('html', false, false),
+                          $this->tagPattern('head', false, false),
+                          $this->tagPattern('head', true, false),
+                          $this->tagPattern('html', true, false),
+                          $this->tagPattern(array(
+                          'body', 'frameset', 'frame', 'p', 'div',
+                          'table','span','a'), 'maybe', 'maybe'));
+        $key_tags_pos = array();
+        foreach ($key_tags as $pat) {
+            $matches = array();
+            preg_match($pat, $html_string, $matches, PREG_OFFSET_CAPTURE);
+            if($matches) {
+                $key_tags_pos[] = $matches[0][1];
+            } else {
+                $key_tags_pos[] = null;
+            }
+        }
+        // no opening head tag
+        if (is_null($key_tags_pos[1])) {
+            return array();
+        }
+        // the effective </head> is the min of the following
+        if (is_null($key_tags_pos[2])) {
+            $key_tags_pos[2] = strlen($html_string);
+        }
+        foreach (array($key_tags_pos[3], $key_tags_pos[4]) as $pos) {
+            if (!is_null($pos) && $pos < $key_tags_pos[2]) {
+                $key_tags_pos[2] = $pos;
+            }
+        }
+        // closing head tag comes before opening head tag
+        if ($key_tags_pos[1] > $key_tags_pos[2]) {
+            return array();
+        }
+        // if there is an opening html tag, make sure the opening head tag
+        // comes after it
+        if (!is_null($key_tags_pos[0]) && $key_tags_pos[1] < $key_tags_pos[0]) {
+            return array();
+        }
+        $html_string = substr($html_string, $key_tags_pos[1],
+                              ($key_tags_pos[2]-$key_tags_pos[1]));
+
+        $link_data = array();
+        $link_matches = array();
+        
+        if (!preg_match_all($this->tagPattern('meta', false, 'maybe'),
+                            $html_string, $link_matches)) {
+            return array();
+        }
+
+        foreach ($link_matches[0] as $link) {
+            $attr_matches = array();
+            preg_match_all($this->_attr_find, $link, $attr_matches);
+            $link_attrs = array();
+            foreach ($attr_matches[0] as $index => $full_match) {
+                $name = $attr_matches[1][$index];
+                $value = $this->replaceEntities(
+                              $this->removeQuotes($attr_matches[2][$index]));
+
+                $link_attrs[strtolower($name)] = $value;
+            }
+            $link_data[] = $link_attrs;
+        }
+
+        return $link_data;
+    }
+
+    /**
+     * Looks for a META tag with an "http-equiv" attribute whose value
+     * is one of ("x-xrds-location", "x-yadis-location"), ignoring
+     * case.  If such a META tag is found, its "content" attribute
+     * value is returned.
+     *
+     * @param string $html_string An HTML document in string format
+     * @return mixed $content The "content" attribute value of the
+     * META tag, if found, or null if no such tag was found.
+     */
+    function getHTTPEquiv($html_string)
+    {
+        $meta_tags = $this->getMetaTags($html_string);
+
+        if ($meta_tags) {
+            foreach ($meta_tags as $tag) {
+                if (array_key_exists('http-equiv', $tag) &&
+                    (in_array(strtolower($tag['http-equiv']),
+                              array('x-xrds-location', 'x-yadis-location'))) &&
+                    array_key_exists('content', $tag)) {
+                    return $tag['content'];
+                }
+            }
+        }
+
+        return null;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/Yadis/PlainHTTPFetcher.php b/inc/lib/Auth/Yadis/PlainHTTPFetcher.php
new file mode 100644 (file)
index 0000000..3e0ca2b
--- /dev/null
@@ -0,0 +1,249 @@
+<?php
+
+/**
+ * This module contains the plain non-curl HTTP fetcher
+ * implementation.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Interface import
+ */
+require_once "Auth/Yadis/HTTPFetcher.php";
+
+/**
+ * This class implements a plain, hand-built socket-based fetcher
+ * which will be used in the event that CURL is unavailable.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_PlainHTTPFetcher extends Auth_Yadis_HTTPFetcher {
+    /**
+     * Does this fetcher support SSL URLs?
+     */
+    function supportsSSL()
+    {
+        return function_exists('openssl_open');
+    }
+
+    function get($url, $extra_headers = null)
+    {
+        if (!$this->canFetchURL($url)) {
+            return null;
+        }
+
+        $redir = true;
+
+        $stop = time() + $this->timeout;
+        $off = $this->timeout;
+
+        while ($redir && ($off > 0)) {
+
+            $parts = parse_url($url);
+
+            $specify_port = true;
+
+            // Set a default port.
+            if (!array_key_exists('port', $parts)) {
+                $specify_port = false;
+                if ($parts['scheme'] == 'http') {
+                    $parts['port'] = 80;
+                } elseif ($parts['scheme'] == 'https') {
+                    $parts['port'] = 443;
+                } else {
+                    return null;
+                }
+            }
+
+            if (!array_key_exists('path', $parts)) {
+                $parts['path'] = '/';
+            }
+
+            $host = $parts['host'];
+
+            if ($parts['scheme'] == 'https') {
+                $host = 'ssl://' . $host;
+            }
+
+            $user_agent = Auth_OpenID_USER_AGENT;
+
+            $headers = array(
+                             "GET ".$parts['path'].
+                             (array_key_exists('query', $parts) ?
+                              "?".$parts['query'] : "").
+                                 " HTTP/1.0",
+                             "User-Agent: $user_agent",
+                             "Host: ".$parts['host'].
+                                ($specify_port ? ":".$parts['port'] : ""),
+                             "Port: ".$parts['port']);
+
+            $errno = 0;
+            $errstr = '';
+
+            if ($extra_headers) {
+                foreach ($extra_headers as $h) {
+                    $headers[] = $h;
+                }
+            }
+
+            @$sock = fsockopen($host, $parts['port'], $errno, $errstr,
+                               $this->timeout);
+            if ($sock === false) {
+                return false;
+            }
+
+            stream_set_timeout($sock, $this->timeout);
+
+            fputs($sock, implode("\r\n", $headers) . "\r\n\r\n");
+
+            $data = "";
+            $kilobytes = 0;
+            while (!feof($sock) &&
+                   $kilobytes < Auth_OpenID_FETCHER_MAX_RESPONSE_KB ) {
+                $data .= fgets($sock, 1024);
+                $kilobytes += 1;
+            }
+
+            fclose($sock);
+
+            // Split response into header and body sections
+            list($headers, $body) = explode("\r\n\r\n", $data, 2);
+            $headers = explode("\r\n", $headers);
+
+            $http_code = explode(" ", $headers[0]);
+            $code = $http_code[1];
+
+            if (in_array($code, array('301', '302'))) {
+                $url = $this->_findRedirect($headers);
+                $redir = true;
+            } else {
+                $redir = false;
+            }
+
+            $off = $stop - time();
+        }
+
+        $new_headers = array();
+
+        foreach ($headers as $header) {
+            if (preg_match("/:/", $header)) {
+                $parts = explode(": ", $header, 2);
+
+                if (count($parts) == 2) {
+                    list($name, $value) = $parts;
+                    $new_headers[$name] = $value;
+                }
+            }
+
+        }
+
+        return new Auth_Yadis_HTTPResponse($url, $code, $new_headers, $body);
+    }
+
+    function post($url, $body, $extra_headers = null)
+    {
+        if (!$this->canFetchURL($url)) {
+            return null;
+        }
+
+        $parts = parse_url($url);
+
+        $headers = array();
+
+        $post_path = $parts['path'];
+        if (isset($parts['query'])) {
+            $post_path .= '?' . $parts['query'];
+        }
+
+        $headers[] = "POST ".$post_path." HTTP/1.0";
+        $headers[] = "Host: " . $parts['host'];
+        $headers[] = "Content-type: application/x-www-form-urlencoded";
+        $headers[] = "Content-length: " . strval(strlen($body));
+
+        if ($extra_headers &&
+            is_array($extra_headers)) {
+            $headers = array_merge($headers, $extra_headers);
+        }
+
+        // Join all headers together.
+        $all_headers = implode("\r\n", $headers);
+
+        // Add headers, two newlines, and request body.
+        $request = $all_headers . "\r\n\r\n" . $body;
+
+        // Set a default port.
+        if (!array_key_exists('port', $parts)) {
+            if ($parts['scheme'] == 'http') {
+                $parts['port'] = 80;
+            } elseif ($parts['scheme'] == 'https') {
+                $parts['port'] = 443;
+            } else {
+                return null;
+            }
+        }
+
+        if ($parts['scheme'] == 'https') {
+            $parts['host'] = sprintf("ssl://%s", $parts['host']);
+        }
+
+        // Connect to the remote server.
+        $errno = 0;
+        $errstr = '';
+
+        $sock = fsockopen($parts['host'], $parts['port'], $errno, $errstr,
+                          $this->timeout);
+
+        if ($sock === false) {
+            return null;
+        }
+
+        stream_set_timeout($sock, $this->timeout);
+
+        // Write the POST request.
+        fputs($sock, $request);
+
+        // Get the response from the server.
+        $response = "";
+        while (!feof($sock)) {
+            if ($data = fgets($sock, 128)) {
+                $response .= $data;
+            } else {
+                break;
+            }
+        }
+
+        // Split the request into headers and body.
+        list($headers, $response_body) = explode("\r\n\r\n", $response, 2);
+
+        $headers = explode("\r\n", $headers);
+
+        // Expect the first line of the headers data to be something
+        // like HTTP/1.1 200 OK.  Split the line on spaces and take
+        // the second token, which should be the return code.
+        $http_code = explode(" ", $headers[0]);
+        $code = $http_code[1];
+
+        $new_headers = array();
+
+        foreach ($headers as $header) {
+            if (preg_match("/:/", $header)) {
+                list($name, $value) = explode(": ", $header, 2);
+                $new_headers[$name] = $value;
+            }
+
+        }
+
+        return new Auth_Yadis_HTTPResponse($url, $code,
+                                           $new_headers, $response_body);
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/inc/lib/Auth/Yadis/XML.php b/inc/lib/Auth/Yadis/XML.php
new file mode 100644 (file)
index 0000000..81b2ce2
--- /dev/null
@@ -0,0 +1,374 @@
+<?php
+
+/**
+ * XML-parsing classes to wrap the domxml and DOM extensions for PHP 4
+ * and 5, respectively.
+ *
+ * @package OpenID
+ */
+
+/**
+ * The base class for wrappers for available PHP XML-parsing
+ * extensions.  To work with this Yadis library, subclasses of this
+ * class MUST implement the API as defined in the remarks for this
+ * class.  Subclasses of Auth_Yadis_XMLParser are used to wrap
+ * particular PHP XML extensions such as 'domxml'.  These are used
+ * internally by the library depending on the availability of
+ * supported PHP XML extensions.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_XMLParser {
+    /**
+     * Initialize an instance of Auth_Yadis_XMLParser with some
+     * XML and namespaces.  This SHOULD NOT be overridden by
+     * subclasses.
+     *
+     * @param string $xml_string A string of XML to be parsed.
+     * @param array $namespace_map An array of ($ns_name => $ns_uri)
+     * to be registered with the XML parser.  May be empty.
+     * @return boolean $result True if the initialization and
+     * namespace registration(s) succeeded; false otherwise.
+     */
+    function init($xml_string, $namespace_map)
+    {
+        if (!$this->setXML($xml_string)) {
+            return false;
+        }
+
+        foreach ($namespace_map as $prefix => $uri) {
+            if (!$this->registerNamespace($prefix, $uri)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Register a namespace with the XML parser.  This should be
+     * overridden by subclasses.
+     *
+     * @param string $prefix The namespace prefix to appear in XML tag
+     * names.
+     *
+     * @param string $uri The namespace URI to be used to identify the
+     * namespace in the XML.
+     *
+     * @return boolean $result True if the registration succeeded;
+     * false otherwise.
+     */
+    function registerNamespace($prefix, $uri)
+    {
+        // Not implemented.
+    }
+
+    /**
+     * Set this parser object's XML payload.  This should be
+     * overridden by subclasses.
+     *
+     * @param string $xml_string The XML string to pass to this
+     * object's XML parser.
+     *
+     * @return boolean $result True if the initialization succeeded;
+     * false otherwise.
+     */
+    function setXML($xml_string)
+    {
+        // Not implemented.
+    }
+
+    /**
+     * Evaluate an XPath expression and return the resulting node
+     * list.  This should be overridden by subclasses.
+     *
+     * @param string $xpath The XPath expression to be evaluated.
+     *
+     * @param mixed $node A node object resulting from a previous
+     * evalXPath call.  This node, if specified, provides the context
+     * for the evaluation of this xpath expression.
+     *
+     * @return array $node_list An array of matching opaque node
+     * objects to be used with other methods of this parser class.
+     */
+    function &evalXPath($xpath, $node = null)
+    {
+        // Not implemented.
+    }
+
+    /**
+     * Return the textual content of a specified node.
+     *
+     * @param mixed $node A node object from a previous call to
+     * $this->evalXPath().
+     *
+     * @return string $content The content of this node.
+     */
+    function content($node)
+    {
+        // Not implemented.
+    }
+
+    /**
+     * Return the attributes of a specified node.
+     *
+     * @param mixed $node A node object from a previous call to
+     * $this->evalXPath().
+     *
+     * @return array $attrs An array mapping attribute names to
+     * values.
+     */
+    function attributes($node)
+    {
+        // Not implemented.
+    }
+}
+
+/**
+ * This concrete implementation of Auth_Yadis_XMLParser implements
+ * the appropriate API for the 'domxml' extension which is typically
+ * packaged with PHP 4.  This class will be used whenever the 'domxml'
+ * extension is detected.  See the Auth_Yadis_XMLParser class for
+ * details on this class's methods.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_domxml extends Auth_Yadis_XMLParser {
+    function Auth_Yadis_domxml()
+    {
+        $this->xml = null;
+        $this->doc = null;
+        $this->xpath = null;
+        $this->errors = array();
+    }
+
+    function setXML($xml_string)
+    {
+        $this->xml = $xml_string;
+        $this->doc = @domxml_open_mem($xml_string, DOMXML_LOAD_PARSING,
+                                      $this->errors);
+
+        if (!$this->doc) {
+            return false;
+        }
+
+        $this->xpath = $this->doc->xpath_new_context();
+
+        return true;
+    }
+
+    function registerNamespace($prefix, $uri)
+    {
+        return xpath_register_ns($this->xpath, $prefix, $uri);
+    }
+
+    function &evalXPath($xpath, $node = null)
+    {
+        if ($node) {
+            $result = @$this->xpath->xpath_eval($xpath, $node);
+        } else {
+            $result = @$this->xpath->xpath_eval($xpath);
+        }
+
+        if (!$result) {
+            $n = array();
+            return $n;
+        }
+
+        if (!$result->nodeset) {
+            $n = array();
+            return $n;
+        }
+
+        return $result->nodeset;
+    }
+
+    function content($node)
+    {
+        if ($node) {
+            return $node->get_content();
+        }
+    }
+
+    function attributes($node)
+    {
+        if ($node) {
+            $arr = $node->attributes();
+            $result = array();
+
+            if ($arr) {
+                foreach ($arr as $attrnode) {
+                    $result[$attrnode->name] = $attrnode->value;
+                }
+            }
+
+            return $result;
+        }
+    }
+}
+
+/**
+ * This concrete implementation of Auth_Yadis_XMLParser implements
+ * the appropriate API for the 'dom' extension which is typically
+ * packaged with PHP 5.  This class will be used whenever the 'dom'
+ * extension is detected.  See the Auth_Yadis_XMLParser class for
+ * details on this class's methods.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_dom extends Auth_Yadis_XMLParser {
+    function Auth_Yadis_dom()
+    {
+        $this->xml = null;
+        $this->doc = null;
+        $this->xpath = null;
+        $this->errors = array();
+    }
+
+    function setXML($xml_string)
+    {
+        $this->xml = $xml_string;
+        $this->doc = new DOMDocument;
+
+        if (!$this->doc) {
+            return false;
+        }
+
+        if (!@$this->doc->loadXML($xml_string)) {
+            return false;
+        }
+
+        $this->xpath = new DOMXPath($this->doc);
+
+        if ($this->xpath) {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    function registerNamespace($prefix, $uri)
+    {
+        return $this->xpath->registerNamespace($prefix, $uri);
+    }
+
+    function &evalXPath($xpath, $node = null)
+    {
+        if ($node) {
+            $result = @$this->xpath->query($xpath, $node);
+        } else {
+            $result = @$this->xpath->query($xpath);
+        }
+
+        $n = array();
+
+        if (!$result) {
+            return $n;
+        }
+
+        for ($i = 0; $i < $result->length; $i++) {
+            $n[] = $result->item($i);
+        }
+
+        return $n;
+    }
+
+    function content($node)
+    {
+        if ($node) {
+            return $node->textContent;
+        }
+    }
+
+    function attributes($node)
+    {
+        if ($node) {
+            $arr = $node->attributes;
+            $result = array();
+
+            if ($arr) {
+                for ($i = 0; $i < $arr->length; $i++) {
+                    $node = $arr->item($i);
+                    $result[$node->nodeName] = $node->nodeValue;
+                }
+            }
+
+            return $result;
+        }
+    }
+}
+
+global $__Auth_Yadis_defaultParser;
+$__Auth_Yadis_defaultParser = null;
+
+/**
+ * Set a default parser to override the extension-driven selection of
+ * available parser classes.  This is helpful in a test environment or
+ * one in which multiple parsers can be used but one is more
+ * desirable.
+ *
+ * @param Auth_Yadis_XMLParser $parser An instance of a
+ * Auth_Yadis_XMLParser subclass.
+ */
+function Auth_Yadis_setDefaultParser(&$parser)
+{
+    global $__Auth_Yadis_defaultParser;
+    $__Auth_Yadis_defaultParser =& $parser;
+}
+
+function Auth_Yadis_getSupportedExtensions()
+{
+    return array(
+                 'dom' => array('classname' => 'Auth_Yadis_dom',
+                       'libname' => array('dom.so', 'dom.dll')),
+                 'domxml' => array('classname' => 'Auth_Yadis_domxml',
+                       'libname' => array('domxml.so', 'php_domxml.dll')),
+                 );
+}
+
+/**
+ * Returns an instance of a Auth_Yadis_XMLParser subclass based on
+ * the availability of PHP extensions for XML parsing.  If
+ * Auth_Yadis_setDefaultParser has been called, the parser used in
+ * that call will be returned instead.
+ */
+function &Auth_Yadis_getXMLParser()
+{
+    global $__Auth_Yadis_defaultParser;
+
+    if (isset($__Auth_Yadis_defaultParser)) {
+        return $__Auth_Yadis_defaultParser;
+    }
+
+    $p = null;
+    $classname = null;
+
+    $extensions = Auth_Yadis_getSupportedExtensions();
+
+    // Return a wrapper for the resident implementation, if any.
+    foreach ($extensions as $name => $params) {
+        if (!extension_loaded($name)) {
+            foreach ($params['libname'] as $libname) {
+                if (@dl($libname)) {
+                    $classname = $params['classname'];
+                }
+            }
+        } else {
+            $classname = $params['classname'];
+        }
+        if (isset($classname)) {
+            $p = new $classname();
+            return $p;
+        }
+    }
+
+    if (!isset($p)) {
+        trigger_error('No XML parser was found', E_USER_ERROR);
+    } else {
+        Auth_Yadis_setDefaultParser($p);
+    }
+
+    return $p;
+}
+
+?>
diff --git a/inc/lib/Auth/Yadis/XRDS.php b/inc/lib/Auth/Yadis/XRDS.php
new file mode 100644 (file)
index 0000000..6b39417
--- /dev/null
@@ -0,0 +1,478 @@
+<?php
+
+/**
+ * This module contains the XRDS parsing code.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Require the XPath implementation.
+ */
+require_once 'Auth/Yadis/XML.php';
+
+/**
+ * This match mode means a given service must match ALL filters passed
+ * to the Auth_Yadis_XRDS::services() call.
+ */
+define('SERVICES_YADIS_MATCH_ALL', 101);
+
+/**
+ * This match mode means a given service must match ANY filters (at
+ * least one) passed to the Auth_Yadis_XRDS::services() call.
+ */
+define('SERVICES_YADIS_MATCH_ANY', 102);
+
+/**
+ * The priority value used for service elements with no priority
+ * specified.
+ */
+define('SERVICES_YADIS_MAX_PRIORITY', pow(2, 30));
+
+/**
+ * XRD XML namespace
+ */
+define('Auth_Yadis_XMLNS_XRD_2_0', 'xri://$xrd*($v*2.0)');
+
+/**
+ * XRDS XML namespace
+ */
+define('Auth_Yadis_XMLNS_XRDS', 'xri://$xrds');
+
+function Auth_Yadis_getNSMap()
+{
+    return array('xrds' => Auth_Yadis_XMLNS_XRDS,
+                 'xrd' => Auth_Yadis_XMLNS_XRD_2_0);
+}
+
+/**
+ * @access private
+ */
+function Auth_Yadis_array_scramble($arr)
+{
+    $result = array();
+
+    while (count($arr)) {
+        $index = array_rand($arr, 1);
+        $result[] = $arr[$index];
+        unset($arr[$index]);
+    }
+
+    return $result;
+}
+
+/**
+ * This class represents a <Service> element in an XRDS document.
+ * Objects of this type are returned by
+ * Auth_Yadis_XRDS::services() and
+ * Auth_Yadis_Yadis::services().  Each object corresponds directly
+ * to a <Service> element in the XRDS and supplies a
+ * getElements($name) method which you should use to inspect the
+ * element's contents.  See {@link Auth_Yadis_Yadis} for more
+ * information on the role this class plays in Yadis discovery.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_Service {
+
+    /**
+     * Creates an empty service object.
+     */
+    function Auth_Yadis_Service()
+    {
+        $this->element = null;
+        $this->parser = null;
+    }
+
+    /**
+     * Return the URIs in the "Type" elements, if any, of this Service
+     * element.
+     *
+     * @return array $type_uris An array of Type URI strings.
+     */
+    function getTypes()
+    {
+        $t = array();
+        foreach ($this->getElements('xrd:Type') as $elem) {
+            $c = $this->parser->content($elem);
+            if ($c) {
+                $t[] = $c;
+            }
+        }
+        return $t;
+    }
+
+    function matchTypes($type_uris)
+    {
+        $result = array();
+
+        foreach ($this->getTypes() as $typ) {
+            if (in_array($typ, $type_uris)) {
+                $result[] = $typ;
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Return the URIs in the "URI" elements, if any, of this Service
+     * element.  The URIs are returned sorted in priority order.
+     *
+     * @return array $uris An array of URI strings.
+     */
+    function getURIs()
+    {
+        $uris = array();
+        $last = array();
+
+        foreach ($this->getElements('xrd:URI') as $elem) {
+            $uri_string = $this->parser->content($elem);
+            $attrs = $this->parser->attributes($elem);
+            if ($attrs &&
+                array_key_exists('priority', $attrs)) {
+                $priority = intval($attrs['priority']);
+                if (!array_key_exists($priority, $uris)) {
+                    $uris[$priority] = array();
+                }
+
+                $uris[$priority][] = $uri_string;
+            } else {
+                $last[] = $uri_string;
+            }
+        }
+
+        $keys = array_keys($uris);
+        sort($keys);
+
+        // Rebuild array of URIs.
+        $result = array();
+        foreach ($keys as $k) {
+            $new_uris = Auth_Yadis_array_scramble($uris[$k]);
+            $result = array_merge($result, $new_uris);
+        }
+
+        $result = array_merge($result,
+                              Auth_Yadis_array_scramble($last));
+
+        return $result;
+    }
+
+    /**
+     * Returns the "priority" attribute value of this <Service>
+     * element, if the attribute is present.  Returns null if not.
+     *
+     * @return mixed $result Null or integer, depending on whether
+     * this Service element has a 'priority' attribute.
+     */
+    function getPriority()
+    {
+        $attributes = $this->parser->attributes($this->element);
+
+        if (array_key_exists('priority', $attributes)) {
+            return intval($attributes['priority']);
+        }
+
+        return null;
+    }
+
+    /**
+     * Used to get XML elements from this object's <Service> element.
+     *
+     * This is what you should use to get all custom information out
+     * of this element. This is used by service filter functions to
+     * determine whether a service element contains specific tags,
+     * etc.  NOTE: this only considers elements which are direct
+     * children of the <Service> element for this object.
+     *
+     * @param string $name The name of the element to look for
+     * @return array $list An array of elements with the specified
+     * name which are direct children of the <Service> element.  The
+     * nodes returned by this function can be passed to $this->parser
+     * methods (see {@link Auth_Yadis_XMLParser}).
+     */
+    function getElements($name)
+    {
+        return $this->parser->evalXPath($name, $this->element);
+    }
+}
+
+/*
+ * Return the expiration date of this XRD element, or None if no
+ * expiration was specified.
+ *
+ * @param $default The value to use as the expiration if no expiration
+ * was specified in the XRD.
+ */
+function Auth_Yadis_getXRDExpiration($xrd_element, $default=null)
+{
+    $expires_element = $xrd_element->$parser->evalXPath('/xrd:Expires');
+    if ($expires_element === null) {
+        return $default;
+    } else {
+        $expires_string = $expires_element->text;
+
+        // Will raise ValueError if the string is not the expected
+        // format
+        $t = strptime($expires_string, "%Y-%m-%dT%H:%M:%SZ");
+
+        if ($t === false) {
+            return false;
+        }
+
+        // [int $hour [, int $minute [, int $second [,
+        //  int $month [, int $day [, int $year ]]]]]]
+        return mktime($t['tm_hour'], $t['tm_min'], $t['tm_sec'],
+                      $t['tm_mon'], $t['tm_day'], $t['tm_year']);
+    }
+}
+
+/**
+ * This class performs parsing of XRDS documents.
+ *
+ * You should not instantiate this class directly; rather, call
+ * parseXRDS statically:
+ *
+ * <pre>  $xrds = Auth_Yadis_XRDS::parseXRDS($xml_string);</pre>
+ *
+ * If the XRDS can be parsed and is valid, an instance of
+ * Auth_Yadis_XRDS will be returned.  Otherwise, null will be
+ * returned.  This class is used by the Auth_Yadis_Yadis::discover
+ * method.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_XRDS {
+
+    /**
+     * Instantiate a Auth_Yadis_XRDS object.  Requires an XPath
+     * instance which has been used to parse a valid XRDS document.
+     */
+    function Auth_Yadis_XRDS(&$xmlParser, &$xrdNodes)
+    {
+        $this->parser =& $xmlParser;
+        $this->xrdNode = $xrdNodes[count($xrdNodes) - 1];
+        $this->allXrdNodes =& $xrdNodes;
+        $this->serviceList = array();
+        $this->_parse();
+    }
+
+    /**
+     * Parse an XML string (XRDS document) and return either a
+     * Auth_Yadis_XRDS object or null, depending on whether the
+     * XRDS XML is valid.
+     *
+     * @param string $xml_string An XRDS XML string.
+     * @return mixed $xrds An instance of Auth_Yadis_XRDS or null,
+     * depending on the validity of $xml_string
+     */
+    function &parseXRDS($xml_string, $extra_ns_map = null)
+    {
+        $_null = null;
+
+        if (!$xml_string) {
+            return $_null;
+        }
+
+        $parser = Auth_Yadis_getXMLParser();
+
+        $ns_map = Auth_Yadis_getNSMap();
+
+        if ($extra_ns_map && is_array($extra_ns_map)) {
+            $ns_map = array_merge($ns_map, $extra_ns_map);
+        }
+
+        if (!($parser && $parser->init($xml_string, $ns_map))) {
+            return $_null;
+        }
+
+        // Try to get root element.
+        $root = $parser->evalXPath('/xrds:XRDS[1]');
+        if (!$root) {
+            return $_null;
+        }
+
+        if (is_array($root)) {
+            $root = $root[0];
+        }
+
+        $attrs = $parser->attributes($root);
+
+        if (array_key_exists('xmlns:xrd', $attrs) &&
+            $attrs['xmlns:xrd'] != Auth_Yadis_XMLNS_XRDS) {
+            return $_null;
+        } else if (array_key_exists('xmlns', $attrs) &&
+                   preg_match('/xri/', $attrs['xmlns']) &&
+                   $attrs['xmlns'] != Auth_Yadis_XMLNS_XRD_2_0) {
+            return $_null;
+        }
+
+        // Get the last XRD node.
+        $xrd_nodes = $parser->evalXPath('/xrds:XRDS[1]/xrd:XRD');
+
+        if (!$xrd_nodes) {
+            return $_null;
+        }
+
+        $xrds = new Auth_Yadis_XRDS($parser, $xrd_nodes);
+        return $xrds;
+    }
+
+    /**
+     * @access private
+     */
+    function _addService($priority, $service)
+    {
+        $priority = intval($priority);
+
+        if (!array_key_exists($priority, $this->serviceList)) {
+            $this->serviceList[$priority] = array();
+        }
+
+        $this->serviceList[$priority][] = $service;
+    }
+
+    /**
+     * Creates the service list using nodes from the XRDS XML
+     * document.
+     *
+     * @access private
+     */
+    function _parse()
+    {
+        $this->serviceList = array();
+
+        $services = $this->parser->evalXPath('xrd:Service', $this->xrdNode);
+
+        foreach ($services as $node) {
+            $s = new Auth_Yadis_Service();
+            $s->element = $node;
+            $s->parser =& $this->parser;
+
+            $priority = $s->getPriority();
+
+            if ($priority === null) {
+                $priority = SERVICES_YADIS_MAX_PRIORITY;
+            }
+
+            $this->_addService($priority, $s);
+        }
+    }
+
+    /**
+     * Returns a list of service objects which correspond to <Service>
+     * elements in the XRDS XML document for this object.
+     *
+     * Optionally, an array of filter callbacks may be given to limit
+     * the list of returned service objects.  Furthermore, the default
+     * mode is to return all service objects which match ANY of the
+     * specified filters, but $filter_mode may be
+     * SERVICES_YADIS_MATCH_ALL if you want to be sure that the
+     * returned services match all the given filters.  See {@link
+     * Auth_Yadis_Yadis} for detailed usage information on filter
+     * functions.
+     *
+     * @param mixed $filters An array of callbacks to filter the
+     * returned services, or null if all services are to be returned.
+     * @param integer $filter_mode SERVICES_YADIS_MATCH_ALL or
+     * SERVICES_YADIS_MATCH_ANY, depending on whether the returned
+     * services should match ALL or ANY of the specified filters,
+     * respectively.
+     * @return mixed $services An array of {@link
+     * Auth_Yadis_Service} objects if $filter_mode is a valid
+     * mode; null if $filter_mode is an invalid mode (i.e., not
+     * SERVICES_YADIS_MATCH_ANY or SERVICES_YADIS_MATCH_ALL).
+     */
+    function services($filters = null,
+                      $filter_mode = SERVICES_YADIS_MATCH_ANY)
+    {
+
+        $pri_keys = array_keys($this->serviceList);
+        sort($pri_keys, SORT_NUMERIC);
+
+        // If no filters are specified, return the entire service
+        // list, ordered by priority.
+        if (!$filters ||
+            (!is_array($filters))) {
+
+            $result = array();
+            foreach ($pri_keys as $pri) {
+                $result = array_merge($result, $this->serviceList[$pri]);
+            }
+
+            return $result;
+        }
+
+        // If a bad filter mode is specified, return null.
+        if (!in_array($filter_mode, array(SERVICES_YADIS_MATCH_ANY,
+                                          SERVICES_YADIS_MATCH_ALL))) {
+            return null;
+        }
+
+        // Otherwise, use the callbacks in the filter list to
+        // determine which services are returned.
+        $filtered = array();
+
+        foreach ($pri_keys as $priority_value) {
+            $service_obj_list = $this->serviceList[$priority_value];
+
+            foreach ($service_obj_list as $service) {
+
+                $matches = 0;
+
+                foreach ($filters as $filter) {
+                    if (call_user_func_array($filter, array(&$service))) {
+                        $matches++;
+
+                        if ($filter_mode == SERVICES_YADIS_MATCH_ANY) {
+                            $pri = $service->getPriority();
+                            if ($pri === null) {
+                                $pri = SERVICES_YADIS_MAX_PRIORITY;
+                            }
+
+                            if (!array_key_exists($pri, $filtered)) {
+                                $filtered[$pri] = array();
+                            }
+
+                            $filtered[$pri][] = $service;
+                            break;
+                        }
+                    }
+                }
+
+                if (($filter_mode == SERVICES_YADIS_MATCH_ALL) &&
+                    ($matches == count($filters))) {
+
+                    $pri = $service->getPriority();
+                    if ($pri === null) {
+                        $pri = SERVICES_YADIS_MAX_PRIORITY;
+                    }
+
+                    if (!array_key_exists($pri, $filtered)) {
+                        $filtered[$pri] = array();
+                    }
+                    $filtered[$pri][] = $service;
+                }
+            }
+        }
+
+        $pri_keys = array_keys($filtered);
+        sort($pri_keys, SORT_NUMERIC);
+
+        $result = array();
+        foreach ($pri_keys as $pri) {
+            $result = array_merge($result, $filtered[$pri]);
+        }
+
+        return $result;
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/Yadis/XRI.php b/inc/lib/Auth/Yadis/XRI.php
new file mode 100644 (file)
index 0000000..4e34623
--- /dev/null
@@ -0,0 +1,234 @@
+<?php
+
+/**
+ * Routines for XRI resolution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+require_once 'Auth/Yadis/Misc.php';
+require_once 'Auth/Yadis/Yadis.php';
+require_once 'Auth/OpenID.php';
+
+function Auth_Yadis_getDefaultProxy()
+{
+    return 'http://xri.net/';
+}
+
+function Auth_Yadis_getXRIAuthorities()
+{
+    return array('!', '=', '@', '+', '$', '(');
+}
+
+function Auth_Yadis_getEscapeRE()
+{
+    $parts = array();
+    foreach (array_merge(Auth_Yadis_getUCSChars(),
+                         Auth_Yadis_getIPrivateChars()) as $pair) {
+        list($m, $n) = $pair;
+        $parts[] = sprintf("%s-%s", chr($m), chr($n));
+    }
+
+    return sprintf('/[%s]/', implode('', $parts));
+}
+
+function Auth_Yadis_getXrefRE()
+{
+    return '/\((.*?)\)/';
+}
+
+function Auth_Yadis_identifierScheme($identifier)
+{
+    if (Auth_Yadis_startswith($identifier, 'xri://') ||
+        ($identifier &&
+          in_array($identifier[0], Auth_Yadis_getXRIAuthorities()))) {
+        return "XRI";
+    } else {
+        return "URI";
+    }
+}
+
+function Auth_Yadis_toIRINormal($xri)
+{
+    if (!Auth_Yadis_startswith($xri, 'xri://')) {
+        $xri = 'xri://' . $xri;
+    }
+
+    return Auth_Yadis_escapeForIRI($xri);
+}
+
+function _escape_xref($xref_match)
+{
+    $xref = $xref_match[0];
+    $xref = str_replace('/', '%2F', $xref);
+    $xref = str_replace('?', '%3F', $xref);
+    $xref = str_replace('#', '%23', $xref);
+    return $xref;
+}
+
+function Auth_Yadis_escapeForIRI($xri)
+{
+    $xri = str_replace('%', '%25', $xri);
+    $xri = preg_replace_callback(Auth_Yadis_getXrefRE(),
+                                 '_escape_xref', $xri);
+    return $xri;
+}
+
+function Auth_Yadis_toURINormal($xri)
+{
+    return Auth_Yadis_iriToURI(Auth_Yadis_toIRINormal($xri));
+}
+
+function Auth_Yadis_iriToURI($iri)
+{
+    if (1) {
+        return $iri;
+    } else {
+        // According to RFC 3987, section 3.1, "Mapping of IRIs to URIs"
+        return preg_replace_callback(Auth_Yadis_getEscapeRE(),
+                                     'Auth_Yadis_pct_escape_unicode', $iri);
+    }
+}
+
+
+function Auth_Yadis_XRIAppendArgs($url, $args)
+{
+    // Append some arguments to an HTTP query.  Yes, this is just like
+    // OpenID's appendArgs, but with special seasoning for XRI
+    // queries.
+
+    if (count($args) == 0) {
+        return $url;
+    }
+
+    // Non-empty array; if it is an array of arrays, use multisort;
+    // otherwise use sort.
+    if (array_key_exists(0, $args) &&
+        is_array($args[0])) {
+        // Do nothing here.
+    } else {
+        $keys = array_keys($args);
+        sort($keys);
+        $new_args = array();
+        foreach ($keys as $key) {
+            $new_args[] = array($key, $args[$key]);
+        }
+        $args = $new_args;
+    }
+
+    // According to XRI Resolution section "QXRI query parameters":
+    //
+    // "If the original QXRI had a null query component (only a
+    //  leading question mark), or a query component consisting of
+    //  only question marks, one additional leading question mark MUST
+    //  be added when adding any XRI resolution parameters."
+    if (strpos(rtrim($url, '?'), '?') !== false) {
+        $sep = '&';
+    } else {
+        $sep = '?';
+    }
+
+    return $url . $sep . Auth_OpenID::httpBuildQuery($args);
+}
+
+function Auth_Yadis_providerIsAuthoritative($providerID, $canonicalID)
+{
+    $lastbang = strrpos($canonicalID, '!');
+    $p = substr($canonicalID, 0, $lastbang);
+    return $p == $providerID;
+}
+
+function Auth_Yadis_rootAuthority($xri)
+{
+    // Return the root authority for an XRI.
+
+    $root = null;
+
+    if (Auth_Yadis_startswith($xri, 'xri://')) {
+        $xri = substr($xri, 6);
+    }
+
+    $authority = explode('/', $xri, 2);
+    $authority = $authority[0];
+    if ($authority[0] == '(') {
+        // Cross-reference.
+        // XXX: This is incorrect if someone nests cross-references so
+        //   there is another close-paren in there.  Hopefully nobody
+        //   does that before we have a real xriparse function.
+        //   Hopefully nobody does that *ever*.
+        $root = substr($authority, 0, strpos($authority, ')') + 1);
+    } else if (in_array($authority[0], Auth_Yadis_getXRIAuthorities())) {
+        // Other XRI reference.
+        $root = $authority[0];
+    } else {
+        // IRI reference.
+        $_segments = explode("!", $authority);
+        $segments = array();
+        foreach ($_segments as $s) {
+            $segments = array_merge($segments, explode("*", $s));
+        }
+        $root = $segments[0];
+    }
+
+    return Auth_Yadis_XRI($root);
+}
+
+function Auth_Yadis_XRI($xri)
+{
+    if (!Auth_Yadis_startswith($xri, 'xri://')) {
+        $xri = 'xri://' . $xri;
+    }
+    return $xri;
+}
+
+function Auth_Yadis_getCanonicalID($iname, $xrds)
+{
+    // Returns false or a canonical ID value.
+
+    // Now nodes are in reverse order.
+    $xrd_list = array_reverse($xrds->allXrdNodes);
+    $parser =& $xrds->parser;
+    $node = $xrd_list[0];
+
+    $canonicalID_nodes = $parser->evalXPath('xrd:CanonicalID', $node);
+
+    if (!$canonicalID_nodes) {
+        return false;
+    }
+
+    $canonicalID = $canonicalID_nodes[0];
+    $canonicalID = Auth_Yadis_XRI($parser->content($canonicalID));
+
+    $childID = $canonicalID;
+
+    for ($i = 1; $i < count($xrd_list); $i++) {
+        $xrd = $xrd_list[$i];
+
+        $parent_sought = substr($childID, 0, strrpos($childID, '!'));
+        $parentCID = $parser->evalXPath('xrd:CanonicalID', $xrd);
+        if (!$parentCID) {
+            return false;
+        }
+        $parentCID = Auth_Yadis_XRI($parser->content($parentCID[0]));
+
+        if (strcasecmp($parent_sought, $parentCID)) {
+            // raise XRDSFraud.
+            return false;
+        }
+
+        $childID = $parent_sought;
+    }
+
+    $root = Auth_Yadis_rootAuthority($iname);
+    if (!Auth_Yadis_providerIsAuthoritative($root, $childID)) {
+        // raise XRDSFraud.
+        return false;
+    }
+
+    return $canonicalID;
+}
+
+?>
diff --git a/inc/lib/Auth/Yadis/XRIRes.php b/inc/lib/Auth/Yadis/XRIRes.php
new file mode 100644 (file)
index 0000000..4e8e8d0
--- /dev/null
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * Code for using a proxy XRI resolver.
+ */
+
+require_once 'Auth/Yadis/XRDS.php';
+require_once 'Auth/Yadis/XRI.php';
+
+class Auth_Yadis_ProxyResolver {
+    function Auth_Yadis_ProxyResolver(&$fetcher, $proxy_url = null)
+    {
+        $this->fetcher =& $fetcher;
+        $this->proxy_url = $proxy_url;
+        if (!$this->proxy_url) {
+            $this->proxy_url = Auth_Yadis_getDefaultProxy();
+        }
+    }
+
+    function queryURL($xri, $service_type = null)
+    {
+        // trim off the xri:// prefix
+        $qxri = substr(Auth_Yadis_toURINormal($xri), 6);
+        $hxri = $this->proxy_url . $qxri;
+        $args = array(
+                      '_xrd_r' => 'application/xrds+xml'
+                      );
+
+        if ($service_type) {
+            $args['_xrd_t'] = $service_type;
+        } else {
+            // Don't perform service endpoint selection.
+            $args['_xrd_r'] .= ';sep=false';
+        }
+
+        $query = Auth_Yadis_XRIAppendArgs($hxri, $args);
+        return $query;
+    }
+
+    function query($xri, $service_types, $filters = array())
+    {
+        $services = array();
+        $canonicalID = null;
+        foreach ($service_types as $service_type) {
+            $url = $this->queryURL($xri, $service_type);
+            $response = $this->fetcher->get($url);
+            if ($response->status != 200 and $response->status != 206) {
+                continue;
+            }
+            $xrds = Auth_Yadis_XRDS::parseXRDS($response->body);
+            if (!$xrds) {
+                continue;
+            }
+            $canonicalID = Auth_Yadis_getCanonicalID($xri,
+                                                         $xrds);
+
+            if ($canonicalID === false) {
+                return null;
+            }
+
+            $some_services = $xrds->services($filters);
+            $services = array_merge($services, $some_services);
+            // TODO:
+            //  * If we do get hits for multiple service_types, we're
+            //    almost certainly going to have duplicated service
+            //    entries and broken priority ordering.
+        }
+        return array($canonicalID, $services);
+    }
+}
+
+?>
diff --git a/inc/lib/Auth/Yadis/Yadis.php b/inc/lib/Auth/Yadis/Yadis.php
new file mode 100644 (file)
index 0000000..d89f77c
--- /dev/null
@@ -0,0 +1,382 @@
+<?php
+
+/**
+ * The core PHP Yadis implementation.
+ *
+ * PHP versions 4 and 5
+ *
+ * LICENSE: See the COPYING file included in this distribution.
+ *
+ * @package OpenID
+ * @author JanRain, Inc. <openid@janrain.com>
+ * @copyright 2005-2008 Janrain, Inc.
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
+ */
+
+/**
+ * Need both fetcher types so we can use the right one based on the
+ * presence or absence of CURL.
+ */
+require_once "Auth/Yadis/PlainHTTPFetcher.php";
+require_once "Auth/Yadis/ParanoidHTTPFetcher.php";
+
+/**
+ * Need this for parsing HTML (looking for META tags).
+ */
+require_once "Auth/Yadis/ParseHTML.php";
+
+/**
+ * Need this to parse the XRDS document during Yadis discovery.
+ */
+require_once "Auth/Yadis/XRDS.php";
+
+/**
+ * XRDS (yadis) content type
+ */
+define('Auth_Yadis_CONTENT_TYPE', 'application/xrds+xml');
+
+/**
+ * Yadis header
+ */
+define('Auth_Yadis_HEADER_NAME', 'X-XRDS-Location');
+
+/**
+ * Contains the result of performing Yadis discovery on a URI.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_DiscoveryResult {
+
+    // The URI that was passed to the fetcher
+    var $request_uri = null;
+
+    // The result of following redirects from the request_uri
+    var $normalized_uri = null;
+
+    // The URI from which the response text was returned (set to
+    // None if there was no XRDS document found)
+    var $xrds_uri = null;
+
+    var $xrds = null;
+
+    // The content-type returned with the response_text
+    var $content_type = null;
+
+    // The document returned from the xrds_uri
+    var $response_text = null;
+
+    // Did the discovery fail miserably?
+    var $failed = false;
+
+    function Auth_Yadis_DiscoveryResult($request_uri)
+    {
+        // Initialize the state of the object
+        // sets all attributes to None except the request_uri
+        $this->request_uri = $request_uri;
+    }
+
+    function fail()
+    {
+        $this->failed = true;
+    }
+
+    function isFailure()
+    {
+        return $this->failed;
+    }
+
+    /**
+     * Returns the list of service objects as described by the XRDS
+     * document, if this yadis object represents a successful Yadis
+     * discovery.
+     *
+     * @return array $services An array of {@link Auth_Yadis_Service}
+     * objects
+     */
+    function services()
+    {
+        if ($this->xrds) {
+            return $this->xrds->services();
+        }
+
+        return null;
+    }
+
+    function usedYadisLocation()
+    {
+        // Was the Yadis protocol's indirection used?
+        return $this->normalized_uri != $this->xrds_uri;
+    }
+
+    function isXRDS()
+    {
+        // Is the response text supposed to be an XRDS document?
+        return ($this->usedYadisLocation() ||
+                $this->content_type == Auth_Yadis_CONTENT_TYPE);
+    }
+}
+
+/**
+ *
+ * Perform the Yadis protocol on the input URL and return an iterable
+ * of resulting endpoint objects.
+ *
+ * input_url: The URL on which to perform the Yadis protocol
+ *
+ * @return: The normalized identity URL and an iterable of endpoint
+ * objects generated by the filter function.
+ *
+ * xrds_parse_func: a callback which will take (uri, xrds_text) and
+ * return an array of service endpoint objects or null.  Usually
+ * array('Auth_OpenID_ServiceEndpoint', 'fromXRDS').
+ *
+ * discover_func: if not null, a callback which should take (uri) and
+ * return an Auth_Yadis_Yadis object or null.
+ */
+function Auth_Yadis_getServiceEndpoints($input_url, $xrds_parse_func,
+                                        $discover_func=null, $fetcher=null)
+{
+    if ($discover_func === null) {
+        $discover_function = array('Auth_Yadis_Yadis', 'discover');
+    }
+
+    $yadis_result = call_user_func_array($discover_func,
+                                         array($input_url, $fetcher));
+
+    if ($yadis_result === null) {
+        return array($input_url, array());
+    }
+
+    $endpoints = call_user_func_array($xrds_parse_func,
+                      array($yadis_result->normalized_uri,
+                            $yadis_result->response_text));
+
+    if ($endpoints === null) {
+        $endpoints = array();
+    }
+
+    return array($yadis_result->normalized_uri, $endpoints);
+}
+
+/**
+ * This is the core of the PHP Yadis library.  This is the only class
+ * a user needs to use to perform Yadis discovery.  This class
+ * performs the discovery AND stores the result of the discovery.
+ *
+ * First, require this library into your program source:
+ *
+ * <pre>  require_once "Auth/Yadis/Yadis.php";</pre>
+ *
+ * To perform Yadis discovery, first call the "discover" method
+ * statically with a URI parameter:
+ *
+ * <pre>  $http_response = array();
+ *  $fetcher = Auth_Yadis_Yadis::getHTTPFetcher();
+ *  $yadis_object = Auth_Yadis_Yadis::discover($uri,
+ *                                    $http_response, $fetcher);</pre>
+ *
+ * If the discovery succeeds, $yadis_object will be an instance of
+ * {@link Auth_Yadis_Yadis}.  If not, it will be null.  The XRDS
+ * document found during discovery should have service descriptions,
+ * which can be accessed by calling
+ *
+ * <pre>  $service_list = $yadis_object->services();</pre>
+ *
+ * which returns an array of objects which describe each service.
+ * These objects are instances of Auth_Yadis_Service.  Each object
+ * describes exactly one whole Service element, complete with all of
+ * its Types and URIs (no expansion is performed).  The common use
+ * case for using the service objects returned by services() is to
+ * write one or more filter functions and pass those to services():
+ *
+ * <pre>  $service_list = $yadis_object->services(
+ *                               array("filterByURI",
+ *                                     "filterByExtension"));</pre>
+ *
+ * The filter functions (whose names appear in the array passed to
+ * services()) take the following form:
+ *
+ * <pre>  function myFilter(&$service) {
+ *       // Query $service object here.  Return true if the service
+ *       // matches your query; false if not.
+ *  }</pre>
+ *
+ * This is an example of a filter which uses a regular expression to
+ * match the content of URI tags (note that the Auth_Yadis_Service
+ * class provides a getURIs() method which you should use instead of
+ * this contrived example):
+ *
+ * <pre>
+ *  function URIMatcher(&$service) {
+ *      foreach ($service->getElements('xrd:URI') as $uri) {
+ *          if (preg_match("/some_pattern/",
+ *                         $service->parser->content($uri))) {
+ *              return true;
+ *          }
+ *      }
+ *      return false;
+ *  }</pre>
+ *
+ * The filter functions you pass will be called for each service
+ * object to determine which ones match the criteria your filters
+ * specify.  The default behavior is that if a given service object
+ * matches ANY of the filters specified in the services() call, it
+ * will be returned.  You can specify that a given service object will
+ * be returned ONLY if it matches ALL specified filters by changing
+ * the match mode of services():
+ *
+ * <pre>  $yadis_object->services(array("filter1", "filter2"),
+ *                          SERVICES_YADIS_MATCH_ALL);</pre>
+ *
+ * See {@link SERVICES_YADIS_MATCH_ALL} and {@link
+ * SERVICES_YADIS_MATCH_ANY}.
+ *
+ * Services described in an XRDS should have a library which you'll
+ * probably be using.  Those libraries are responsible for defining
+ * filters that can be used with the "services()" call.  If you need
+ * to write your own filter, see the documentation for {@link
+ * Auth_Yadis_Service}.
+ *
+ * @package OpenID
+ */
+class Auth_Yadis_Yadis {
+
+    /**
+     * Returns an HTTP fetcher object.  If the CURL extension is
+     * present, an instance of {@link Auth_Yadis_ParanoidHTTPFetcher}
+     * is returned.  If not, an instance of
+     * {@link Auth_Yadis_PlainHTTPFetcher} is returned.
+     *
+     * If Auth_Yadis_CURL_OVERRIDE is defined, this method will always
+     * return a {@link Auth_Yadis_PlainHTTPFetcher}.
+     */
+    function getHTTPFetcher($timeout = 20)
+    {
+        if (Auth_Yadis_Yadis::curlPresent() &&
+            (!defined('Auth_Yadis_CURL_OVERRIDE'))) {
+            $fetcher = new Auth_Yadis_ParanoidHTTPFetcher($timeout);
+        } else {
+            $fetcher = new Auth_Yadis_PlainHTTPFetcher($timeout);
+        }
+        return $fetcher;
+    }
+
+    function curlPresent()
+    {
+        return function_exists('curl_init');
+    }
+
+    /**
+     * @access private
+     */
+    function _getHeader($header_list, $names)
+    {
+        foreach ($header_list as $name => $value) {
+            foreach ($names as $n) {
+                if (strtolower($name) == strtolower($n)) {
+                    return $value;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * @access private
+     */
+    function _getContentType($content_type_header)
+    {
+        if ($content_type_header) {
+            $parts = explode(";", $content_type_header);
+            return strtolower($parts[0]);
+        }
+    }
+
+    /**
+     * This should be called statically and will build a Yadis
+     * instance if the discovery process succeeds.  This implements
+     * Yadis discovery as specified in the Yadis specification.
+     *
+     * @param string $uri The URI on which to perform Yadis discovery.
+     *
+     * @param array $http_response An array reference where the HTTP
+     * response object will be stored (see {@link
+     * Auth_Yadis_HTTPResponse}.
+     *
+     * @param Auth_Yadis_HTTPFetcher $fetcher An instance of a
+     * Auth_Yadis_HTTPFetcher subclass.
+     *
+     * @param array $extra_ns_map An array which maps namespace names
+     * to namespace URIs to be used when parsing the Yadis XRDS
+     * document.
+     *
+     * @param integer $timeout An optional fetcher timeout, in seconds.
+     *
+     * @return mixed $obj Either null or an instance of
+     * Auth_Yadis_Yadis, depending on whether the discovery
+     * succeeded.
+     */
+    function discover($uri, &$fetcher,
+                      $extra_ns_map = null, $timeout = 20)
+    {
+        $result = new Auth_Yadis_DiscoveryResult($uri);
+
+        $request_uri = $uri;
+        $headers = array("Accept: " . Auth_Yadis_CONTENT_TYPE .
+                         ', text/html; q=0.3, application/xhtml+xml; q=0.5');
+
+        if ($fetcher === null) {
+            $fetcher = Auth_Yadis_Yadis::getHTTPFetcher($timeout);
+        }
+
+        $response = $fetcher->get($uri, $headers);
+
+        if (!$response || ($response->status != 200 and
+                           $response->status != 206)) {
+            $result->fail();
+            return $result;
+        }
+
+        $result->normalized_uri = $response->final_url;
+        $result->content_type = Auth_Yadis_Yadis::_getHeader(
+                                       $response->headers,
+                                       array('content-type'));
+
+        if ($result->content_type &&
+            (Auth_Yadis_Yadis::_getContentType($result->content_type) ==
+             Auth_Yadis_CONTENT_TYPE)) {
+            $result->xrds_uri = $result->normalized_uri;
+        } else {
+            $yadis_location = Auth_Yadis_Yadis::_getHeader(
+                                                 $response->headers,
+                                                 array(Auth_Yadis_HEADER_NAME));
+
+            if (!$yadis_location) {
+                $parser = new Auth_Yadis_ParseHTML();
+                $yadis_location = $parser->getHTTPEquiv($response->body);
+            }
+
+            if ($yadis_location) {
+                $result->xrds_uri = $yadis_location;
+
+                $response = $fetcher->get($yadis_location);
+
+                if ((!$response) || ($response->status != 200 and
+                                     $response->status != 206)) {
+                    $result->fail();
+                    return $result;
+                }
+
+                $result->content_type = Auth_Yadis_Yadis::_getHeader(
+                                                         $response->headers,
+                                                         array('content-type'));
+            }
+        }
+
+        $result->response_text = $response->body;
+        return $result;
+    }
+}
+
+?>
diff --git a/inc/lib/Zend/Exception.php b/inc/lib/Zend/Exception.php
new file mode 100644 (file)
index 0000000..3cb5704
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Exception.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * @category   Zend
+ * @package    Zend
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Exception extends Exception
+{}
+
diff --git a/inc/lib/Zend/Search/Exception.php b/inc/lib/Zend/Search/Exception.php
new file mode 100644 (file)
index 0000000..e9432a2
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Exception.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * Framework base exception
+ */
+require_once 'Zend/Exception.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Exception extends Zend_Exception
+{}
+
diff --git a/inc/lib/Zend/Search/Lucene.php b/inc/lib/Zend/Search/Lucene.php
new file mode 100644 (file)
index 0000000..b595993
--- /dev/null
@@ -0,0 +1,1520 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Lucene.php 17164 2009-07-27 03:59:23Z matthew $
+ */
+
+/** Zend_Search_Lucene_Document */
+require_once 'Zend/Search/Lucene/Document.php';
+
+/** Zend_Search_Lucene_Document_Html */
+require_once 'Zend/Search/Lucene/Document/Html.php';
+
+/** Zend_Search_Lucene_Document_Docx */
+require_once 'Zend/Search/Lucene/Document/Docx.php';
+
+/** Zend_Search_Lucene_Document_Pptx */
+require_once 'Zend/Search/Lucene/Document/Pptx.php';
+
+/** Zend_Search_Lucene_Document_Xlsx */
+require_once 'Zend/Search/Lucene/Document/Xlsx.php';
+
+/** Zend_Search_Lucene_Storage_Directory_Filesystem */
+require_once 'Zend/Search/Lucene/Storage/Directory/Filesystem.php';
+
+/** Zend_Search_Lucene_Storage_File_Memory */
+require_once 'Zend/Search/Lucene/Storage/File/Memory.php';
+
+/** Zend_Search_Lucene_Index_Term */
+require_once 'Zend/Search/Lucene/Index/Term.php';
+
+/** Zend_Search_Lucene_Index_TermInfo */
+require_once 'Zend/Search/Lucene/Index/TermInfo.php';
+
+/** Zend_Search_Lucene_Index_SegmentInfo */
+require_once 'Zend/Search/Lucene/Index/SegmentInfo.php';
+
+/** Zend_Search_Lucene_Index_FieldInfo */
+require_once 'Zend/Search/Lucene/Index/FieldInfo.php';
+
+/** Zend_Search_Lucene_Index_Writer */
+require_once 'Zend/Search/Lucene/Index/Writer.php';
+
+/** Zend_Search_Lucene_Search_QueryParser */
+require_once 'Zend/Search/Lucene/Search/QueryParser.php';
+
+/** Zend_Search_Lucene_Search_QueryHit */
+require_once 'Zend/Search/Lucene/Search/QueryHit.php';
+
+/** Zend_Search_Lucene_Search_Similarity */
+require_once 'Zend/Search/Lucene/Search/Similarity.php';
+
+/** Zend_Search_Lucene_Index_TermsPriorityQueue */
+require_once 'Zend/Search/Lucene/Index/TermsPriorityQueue.php';
+
+/** Zend_Search_Lucene_TermStreamsPriorityQueue */
+require_once 'Zend/Search/Lucene/TermStreamsPriorityQueue.php';
+
+/** Zend_Search_Lucene_Index_DocsFilter */
+require_once 'Zend/Search/Lucene/Index/DocsFilter.php';
+
+/** Zend_Search_Lucene_LockManager */
+require_once 'Zend/Search/Lucene/LockManager.php';
+
+/** Zend_Search_Lucene_Interface */
+require_once 'Zend/Search/Lucene/Interface.php';
+
+/** Zend_Search_Lucene_Proxy */
+require_once 'Zend/Search/Lucene/Proxy.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene implements Zend_Search_Lucene_Interface
+{
+    /**
+     * Default field name for search
+     *
+     * Null means search through all fields
+     *
+     * @var string
+     */
+    private static $_defaultSearchField = null;
+
+    /**
+     * Result set limit
+     *
+     * 0 means no limit
+     *
+     * @var integer
+     */
+    private static $_resultSetLimit = 0;
+
+    /**
+     * Terms per query limit
+     *
+     * 0 means no limit
+     *
+     * @var integer
+     */
+    private static $_termsPerQueryLimit = 1024;
+
+    /**
+     * File system adapter.
+     *
+     * @var Zend_Search_Lucene_Storage_Directory
+     */
+    private $_directory = null;
+
+    /**
+     * File system adapter closing option
+     *
+     * @var boolean
+     */
+    private $_closeDirOnExit = true;
+
+    /**
+     * Writer for this index, not instantiated unless required.
+     *
+     * @var Zend_Search_Lucene_Index_Writer
+     */
+    private $_writer = null;
+
+    /**
+     * Array of Zend_Search_Lucene_Index_SegmentInfo objects for this index.
+     *
+     * @var array Zend_Search_Lucene_Index_SegmentInfo
+     */
+    private $_segmentInfos = array();
+
+    /**
+     * Number of documents in this index.
+     *
+     * @var integer
+     */
+    private $_docCount = 0;
+
+    /**
+     * Flag for index changes
+     *
+     * @var boolean
+     */
+    private $_hasChanges = false;
+
+
+    /**
+     * Signal, that index is already closed, changes are fixed and resources are cleaned up
+     *
+     * @var boolean
+     */
+    private $_closed = false;
+
+    /**
+     * Number of references to the index object
+     *
+     * @var integer
+     */
+    private $_refCount = 0;
+
+    /**
+     * Current segment generation
+     *
+     * @var integer
+     */
+    private $_generation;
+
+    const FORMAT_PRE_2_1 = 0;
+    const FORMAT_2_1     = 1;
+    const FORMAT_2_3     = 2;
+
+
+    /**
+     * Index format version
+     *
+     * @var integer
+     */
+    private $_formatVersion;
+
+    /**
+     * Create index
+     *
+     * @param mixed $directory
+     * @return Zend_Search_Lucene_Interface
+     */
+    public static function create($directory)
+    {
+        return new Zend_Search_Lucene_Proxy(new Zend_Search_Lucene($directory, true));
+    }
+
+    /**
+     * Open index
+     *
+     * @param mixed $directory
+     * @return Zend_Search_Lucene_Interface
+     */
+    public static function open($directory)
+    {
+        return new Zend_Search_Lucene_Proxy(new Zend_Search_Lucene($directory, false));
+    }
+
+    /** Generation retrieving counter */
+    const GENERATION_RETRIEVE_COUNT = 10;
+
+    /** Pause between generation retrieving attempts in milliseconds */
+    const GENERATION_RETRIEVE_PAUSE = 50;
+
+    /**
+     * Get current generation number
+     *
+     * Returns generation number
+     * 0 means pre-2.1 index format
+     * -1 means there are no segments files.
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @return integer
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public static function getActualGeneration(Zend_Search_Lucene_Storage_Directory $directory)
+    {
+        /**
+         * Zend_Search_Lucene uses segments.gen file to retrieve current generation number
+         *
+         * Apache Lucene index format documentation mentions this method only as a fallback method
+         *
+         * Nevertheless we use it according to the performance considerations
+         *
+         * @todo check if we can use some modification of Apache Lucene generation determination algorithm
+         *       without performance problems
+         */
+
+        require_once 'Zend/Search/Lucene/Exception.php';
+        try {
+            for ($count = 0; $count < self::GENERATION_RETRIEVE_COUNT; $count++) {
+                // Try to get generation file
+                $genFile = $directory->getFileObject('segments.gen', false);
+
+                $format = $genFile->readInt();
+                if ($format != (int)0xFFFFFFFE) {
+                    throw new Zend_Search_Lucene_Exception('Wrong segments.gen file format');
+                }
+
+                $gen1 = $genFile->readLong();
+                $gen2 = $genFile->readLong();
+
+                if ($gen1 == $gen2) {
+                    return $gen1;
+                }
+
+                usleep(self::GENERATION_RETRIEVE_PAUSE * 1000);
+            }
+
+            // All passes are failed
+            throw new Zend_Search_Lucene_Exception('Index is under processing now');
+        } catch (Zend_Search_Lucene_Exception $e) {
+            if (strpos($e->getMessage(), 'is not readable') !== false) {
+                try {
+                    // Try to open old style segments file
+                    $segmentsFile = $directory->getFileObject('segments', false);
+
+                    // It's pre-2.1 index
+                    return 0;
+                } catch (Zend_Search_Lucene_Exception $e) {
+                    if (strpos($e->getMessage(), 'is not readable') !== false) {
+                        return -1;
+                    } else {
+                        throw $e;
+                    }
+                }
+            } else {
+                throw $e;
+            }
+        }
+
+        return -1;
+    }
+
+    /**
+     * Get segments file name
+     *
+     * @param integer $generation
+     * @return string
+     */
+    public static function getSegmentFileName($generation)
+    {
+        if ($generation == 0) {
+            return 'segments';
+        }
+
+        return 'segments_' . base_convert($generation, 10, 36);
+    }
+
+    /**
+     * Get index format version
+     *
+     * @return integer
+     */
+    public function getFormatVersion()
+    {
+        return $this->_formatVersion;
+    }
+
+    /**
+     * Set index format version.
+     * Index is converted to this format at the nearest upfdate time
+     *
+     * @param int $formatVersion
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function setFormatVersion($formatVersion)
+    {
+        if ($formatVersion != self::FORMAT_PRE_2_1  &&
+            $formatVersion != self::FORMAT_2_1  &&
+            $formatVersion != self::FORMAT_2_3) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Unsupported index format');
+        }
+
+        $this->_formatVersion = $formatVersion;
+    }
+
+    /**
+     * Read segments file for pre-2.1 Lucene index format
+     *
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private function _readPre21SegmentsFile()
+    {
+        $segmentsFile = $this->_directory->getFileObject('segments');
+
+        $format = $segmentsFile->readInt();
+
+        if ($format != (int)0xFFFFFFFF) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Wrong segments file format');
+        }
+
+        // read version
+        $segmentsFile->readLong();
+
+        // read segment name counter
+        $segmentsFile->readInt();
+
+        $segments = $segmentsFile->readInt();
+
+        $this->_docCount = 0;
+
+        // read segmentInfos
+        for ($count = 0; $count < $segments; $count++) {
+            $segName = $segmentsFile->readString();
+            $segSize = $segmentsFile->readInt();
+            $this->_docCount += $segSize;
+
+            $this->_segmentInfos[$segName] =
+                                new Zend_Search_Lucene_Index_SegmentInfo($this->_directory,
+                                                                         $segName,
+                                                                         $segSize);
+        }
+
+        // Use 2.1 as a target version. Index will be reorganized at update time.
+        $this->_formatVersion = self::FORMAT_2_1;
+    }
+
+    /**
+     * Read segments file
+     *
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private function _readSegmentsFile()
+    {
+        $segmentsFile = $this->_directory->getFileObject(self::getSegmentFileName($this->_generation));
+
+        $format = $segmentsFile->readInt();
+
+        if ($format == (int)0xFFFFFFFC) {
+            $this->_formatVersion = self::FORMAT_2_3;
+        } else if ($format == (int)0xFFFFFFFD) {
+            $this->_formatVersion = self::FORMAT_2_1;
+        } else {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Unsupported segments file format');
+        }
+
+        // read version
+        $segmentsFile->readLong();
+
+        // read segment name counter
+        $segmentsFile->readInt();
+
+        $segments = $segmentsFile->readInt();
+
+        $this->_docCount = 0;
+
+        // read segmentInfos
+        for ($count = 0; $count < $segments; $count++) {
+            $segName = $segmentsFile->readString();
+            $segSize = $segmentsFile->readInt();
+
+            // 2.1+ specific properties
+            $delGen = $segmentsFile->readLong();
+
+            if ($this->_formatVersion == self::FORMAT_2_3) {
+                $docStoreOffset = $segmentsFile->readInt();
+
+                if ($docStoreOffset != (int)0xFFFFFFFF) {
+                    $docStoreSegment        = $segmentsFile->readString();
+                    $docStoreIsCompoundFile = $segmentsFile->readByte();
+
+                    $docStoreOptions = array('offset'     => $docStoreOffset,
+                                             'segment'    => $docStoreSegment,
+                                             'isCompound' => ($docStoreIsCompoundFile == 1));
+                } else {
+                    $docStoreOptions = null;
+                }
+            } else {
+                $docStoreOptions = null;
+            }
+
+            $hasSingleNormFile = $segmentsFile->readByte();
+            $numField          = $segmentsFile->readInt();
+
+            $normGens = array();
+            if ($numField != (int)0xFFFFFFFF) {
+                for ($count1 = 0; $count1 < $numField; $count1++) {
+                    $normGens[] = $segmentsFile->readLong();
+                }
+
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Separate norm files are not supported. Optimize index to use it with Zend_Search_Lucene.');
+            }
+
+            $isCompoundByte     = $segmentsFile->readByte();
+
+            if ($isCompoundByte == 0xFF) {
+                // The segment is not a compound file
+                $isCompound = false;
+            } else if ($isCompoundByte == 0x00) {
+                // The status is unknown
+                $isCompound = null;
+            } else if ($isCompoundByte == 0x01) {
+                // The segment is a compound file
+                $isCompound = true;
+            }
+
+            $this->_docCount += $segSize;
+
+            $this->_segmentInfos[$segName] =
+                                new Zend_Search_Lucene_Index_SegmentInfo($this->_directory,
+                                                                         $segName,
+                                                                         $segSize,
+                                                                         $delGen,
+                                                                         $docStoreOptions,
+                                                                         $hasSingleNormFile,
+                                                                         $isCompound);
+        }
+    }
+
+    /**
+     * Opens the index.
+     *
+     * IndexReader constructor needs Directory as a parameter. It should be
+     * a string with a path to the index folder or a Directory object.
+     *
+     * @param mixed $directory
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct($directory = null, $create = false)
+    {
+        if ($directory === null) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Exception('No index directory specified');
+        }
+
+        if ($directory instanceof Zend_Search_Lucene_Storage_Directory_Filesystem) {
+            $this->_directory      = $directory;
+            $this->_closeDirOnExit = false;
+        } else {
+            $this->_directory      = new Zend_Search_Lucene_Storage_Directory_Filesystem($directory);
+            $this->_closeDirOnExit = true;
+        }
+
+        $this->_segmentInfos = array();
+
+        // Mark index as "under processing" to prevent other processes from premature index cleaning
+        Zend_Search_Lucene_LockManager::obtainReadLock($this->_directory);
+
+        $this->_generation = self::getActualGeneration($this->_directory);
+
+        if ($create) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            try {
+                Zend_Search_Lucene_LockManager::obtainWriteLock($this->_directory);
+            } catch (Zend_Search_Lucene_Exception $e) {
+                Zend_Search_Lucene_LockManager::releaseReadLock($this->_directory);
+
+                if (strpos($e->getMessage(), 'Can\'t obtain exclusive index lock') === false) {
+                    throw $e;
+                } else {
+                    throw new Zend_Search_Lucene_Exception('Can\'t create index. It\'s under processing now');
+                }
+            }
+
+            if ($this->_generation == -1) {
+                // Directory doesn't contain existing index, start from 1
+                $this->_generation = 1;
+                $nameCounter = 0;
+            } else {
+                // Directory contains existing index
+                $segmentsFile = $this->_directory->getFileObject(self::getSegmentFileName($this->_generation));
+                $segmentsFile->seek(12); // 12 = 4 (int, file format marker) + 8 (long, index version)
+
+                $nameCounter = $segmentsFile->readInt();
+                $this->_generation++;
+            }
+
+            Zend_Search_Lucene_Index_Writer::createIndex($this->_directory, $this->_generation, $nameCounter);
+
+            Zend_Search_Lucene_LockManager::releaseWriteLock($this->_directory);
+        }
+
+        if ($this->_generation == -1) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Index doesn\'t exists in the specified directory.');
+        } else if ($this->_generation == 0) {
+            $this->_readPre21SegmentsFile();
+        } else {
+            $this->_readSegmentsFile();
+        }
+    }
+
+    /**
+     * Close current index and free resources
+     */
+    private function _close()
+    {
+        if ($this->_closed) {
+            // index is already closed and resources are cleaned up
+            return;
+        }
+
+        $this->commit();
+
+        // Release "under processing" flag
+        Zend_Search_Lucene_LockManager::releaseReadLock($this->_directory);
+
+        if ($this->_closeDirOnExit) {
+            $this->_directory->close();
+        }
+
+        $this->_directory    = null;
+        $this->_writer       = null;
+        $this->_segmentInfos = null;
+
+        $this->_closed = true;
+    }
+
+    /**
+     * Add reference to the index object
+     *
+     * @internal
+     */
+    public function addReference()
+    {
+        $this->_refCount++;
+    }
+
+    /**
+     * Remove reference from the index object
+     *
+     * When reference count becomes zero, index is closed and resources are cleaned up
+     *
+     * @internal
+     */
+    public function removeReference()
+    {
+        $this->_refCount--;
+
+        if ($this->_refCount == 0) {
+            $this->_close();
+        }
+    }
+
+    /**
+     * Object destructor
+     */
+    public function __destruct()
+    {
+        $this->_close();
+    }
+
+    /**
+     * Returns an instance of Zend_Search_Lucene_Index_Writer for the index
+     *
+     * @return Zend_Search_Lucene_Index_Writer
+     */
+    private function _getIndexWriter()
+    {
+        if (!$this->_writer instanceof Zend_Search_Lucene_Index_Writer) {
+            $this->_writer = new Zend_Search_Lucene_Index_Writer($this->_directory, $this->_segmentInfos, $this->_formatVersion);
+        }
+
+        return $this->_writer;
+    }
+
+
+    /**
+     * Returns the Zend_Search_Lucene_Storage_Directory instance for this index.
+     *
+     * @return Zend_Search_Lucene_Storage_Directory
+     */
+    public function getDirectory()
+    {
+        return $this->_directory;
+    }
+
+
+    /**
+     * Returns the total number of documents in this index (including deleted documents).
+     *
+     * @return integer
+     */
+    public function count()
+    {
+        return $this->_docCount;
+    }
+
+    /**
+     * Returns one greater than the largest possible document number.
+     * This may be used to, e.g., determine how big to allocate a structure which will have
+     * an element for every document number in an index.
+     *
+     * @return integer
+     */
+    public function maxDoc()
+    {
+        return $this->count();
+    }
+
+    /**
+     * Returns the total number of non-deleted documents in this index.
+     *
+     * @return integer
+     */
+    public function numDocs()
+    {
+        $numDocs = 0;
+
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            $numDocs += $segmentInfo->numDocs();
+        }
+
+        return $numDocs;
+    }
+
+    /**
+     * Checks, that document is deleted
+     *
+     * @param integer $id
+     * @return boolean
+     * @throws Zend_Search_Lucene_Exception    Exception is thrown if $id is out of the range
+     */
+    public function isDeleted($id)
+    {
+        if ($id >= $this->_docCount) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Document id is out of the range.');
+        }
+
+        $segmentStartId = 0;
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            if ($segmentStartId + $segmentInfo->count() > $id) {
+                break;
+            }
+
+            $segmentStartId += $segmentInfo->count();
+        }
+
+        return $segmentInfo->isDeleted($id - $segmentStartId);
+    }
+
+    /**
+     * Set default search field.
+     *
+     * Null means, that search is performed through all fields by default
+     *
+     * Default value is null
+     *
+     * @param string $fieldName
+     */
+    public static function setDefaultSearchField($fieldName)
+    {
+        self::$_defaultSearchField = $fieldName;
+    }
+
+    /**
+     * Get default search field.
+     *
+     * Null means, that search is performed through all fields by default
+     *
+     * @return string
+     */
+    public static function getDefaultSearchField()
+    {
+        return self::$_defaultSearchField;
+    }
+
+    /**
+     * Set result set limit.
+     *
+     * 0 (default) means no limit
+     *
+     * @param integer $limit
+     */
+    public static function setResultSetLimit($limit)
+    {
+        self::$_resultSetLimit = $limit;
+    }
+
+    /**
+     * Get result set limit.
+     *
+     * 0 means no limit
+     *
+     * @return integer
+     */
+    public static function getResultSetLimit()
+    {
+        return self::$_resultSetLimit;
+    }
+
+    /**
+     * Set terms per query limit.
+     *
+     * 0 means no limit
+     *
+     * @param integer $limit
+     */
+    public static function setTermsPerQueryLimit($limit)
+    {
+        self::$_termsPerQueryLimit = $limit;
+    }
+
+    /**
+     * Get result set limit.
+     *
+     * 0 (default) means no limit
+     *
+     * @return integer
+     */
+    public static function getTermsPerQueryLimit()
+    {
+        return self::$_termsPerQueryLimit;
+    }
+
+    /**
+     * Retrieve index maxBufferedDocs option
+     *
+     * maxBufferedDocs is a minimal number of documents required before
+     * the buffered in-memory documents are written into a new Segment
+     *
+     * Default value is 10
+     *
+     * @return integer
+     */
+    public function getMaxBufferedDocs()
+    {
+        return $this->_getIndexWriter()->maxBufferedDocs;
+    }
+
+    /**
+     * Set index maxBufferedDocs option
+     *
+     * maxBufferedDocs is a minimal number of documents required before
+     * the buffered in-memory documents are written into a new Segment
+     *
+     * Default value is 10
+     *
+     * @param integer $maxBufferedDocs
+     */
+    public function setMaxBufferedDocs($maxBufferedDocs)
+    {
+        $this->_getIndexWriter()->maxBufferedDocs = $maxBufferedDocs;
+    }
+
+    /**
+     * Retrieve index maxMergeDocs option
+     *
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().
+     * Small values (e.g., less than 10,000) are best for interactive indexing,
+     * as this limits the length of pauses while indexing to a few seconds.
+     * Larger values are best for batched indexing and speedier searches.
+     *
+     * Default value is PHP_INT_MAX
+     *
+     * @return integer
+     */
+    public function getMaxMergeDocs()
+    {
+        return $this->_getIndexWriter()->maxMergeDocs;
+    }
+
+    /**
+     * Set index maxMergeDocs option
+     *
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().
+     * Small values (e.g., less than 10,000) are best for interactive indexing,
+     * as this limits the length of pauses while indexing to a few seconds.
+     * Larger values are best for batched indexing and speedier searches.
+     *
+     * Default value is PHP_INT_MAX
+     *
+     * @param integer $maxMergeDocs
+     */
+    public function setMaxMergeDocs($maxMergeDocs)
+    {
+        $this->_getIndexWriter()->maxMergeDocs = $maxMergeDocs;
+    }
+
+    /**
+     * Retrieve index mergeFactor option
+     *
+     * mergeFactor determines how often segment indices are merged by addDocument().
+     * With smaller values, less RAM is used while indexing,
+     * and searches on unoptimized indices are faster,
+     * but indexing speed is slower.
+     * With larger values, more RAM is used during indexing,
+     * and while searches on unoptimized indices are slower,
+     * indexing is faster.
+     * Thus larger values (> 10) are best for batch index creation,
+     * and smaller values (< 10) for indices that are interactively maintained.
+     *
+     * Default value is 10
+     *
+     * @return integer
+     */
+    public function getMergeFactor()
+    {
+        return $this->_getIndexWriter()->mergeFactor;
+    }
+
+    /**
+     * Set index mergeFactor option
+     *
+     * mergeFactor determines how often segment indices are merged by addDocument().
+     * With smaller values, less RAM is used while indexing,
+     * and searches on unoptimized indices are faster,
+     * but indexing speed is slower.
+     * With larger values, more RAM is used during indexing,
+     * and while searches on unoptimized indices are slower,
+     * indexing is faster.
+     * Thus larger values (> 10) are best for batch index creation,
+     * and smaller values (< 10) for indices that are interactively maintained.
+     *
+     * Default value is 10
+     *
+     * @param integer $maxMergeDocs
+     */
+    public function setMergeFactor($mergeFactor)
+    {
+        $this->_getIndexWriter()->mergeFactor = $mergeFactor;
+    }
+
+    /**
+     * Performs a query against the index and returns an array
+     * of Zend_Search_Lucene_Search_QueryHit objects.
+     * Input is a string or Zend_Search_Lucene_Search_Query.
+     *
+     * @param mixed $query
+     * @return array Zend_Search_Lucene_Search_QueryHit
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function find($query)
+    {
+        if (is_string($query)) {
+            $query = Zend_Search_Lucene_Search_QueryParser::parse($query);
+        }
+
+        if (!$query instanceof Zend_Search_Lucene_Search_Query) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Query must be a string or Zend_Search_Lucene_Search_Query object');
+        }
+
+        $this->commit();
+
+        $hits   = array();
+        $scores = array();
+        $ids    = array();
+
+        $query = $query->rewrite($this)->optimize($this);
+
+        $query->execute($this);
+
+        $topScore = 0;
+
+        foreach ($query->matchedDocs() as $id => $num) {
+            $docScore = $query->score($id, $this);
+            if( $docScore != 0 ) {
+                $hit = new Zend_Search_Lucene_Search_QueryHit($this);
+                $hit->id = $id;
+                $hit->score = $docScore;
+
+                $hits[]   = $hit;
+                $ids[]    = $id;
+                $scores[] = $docScore;
+
+                if ($docScore > $topScore) {
+                    $topScore = $docScore;
+                }
+            }
+
+            if (self::$_resultSetLimit != 0  &&  count($hits) >= self::$_resultSetLimit) {
+                break;
+            }
+        }
+
+        if (count($hits) == 0) {
+            // skip sorting, which may cause a error on empty index
+            return array();
+        }
+
+        if ($topScore > 1) {
+            foreach ($hits as $hit) {
+                $hit->score /= $topScore;
+            }
+        }
+
+        if (func_num_args() == 1) {
+            // sort by scores
+            array_multisort($scores, SORT_DESC, SORT_NUMERIC,
+                            $ids,    SORT_ASC,  SORT_NUMERIC,
+                            $hits);
+        } else {
+            // sort by given field names
+
+            $argList    = func_get_args();
+            $fieldNames = $this->getFieldNames();
+            $sortArgs   = array();
+
+            // PHP 5.3 now expects all arguments to array_multisort be passed by
+            // reference; since constants can't be passed by reference, create 
+            // some placeholder variables.
+            $sortReg    = SORT_REGULAR;
+            $sortAsc    = SORT_ASC;
+            $sortNum    = SORT_NUMERIC;
+
+            require_once 'Zend/Search/Lucene/Exception.php';
+            for ($count = 1; $count < count($argList); $count++) {
+                $fieldName = $argList[$count];
+
+                if (!is_string($fieldName)) {
+                    throw new Zend_Search_Lucene_Exception('Field name must be a string.');
+                }
+
+                if (!in_array($fieldName, $fieldNames)) {
+                    throw new Zend_Search_Lucene_Exception('Wrong field name.');
+                }
+
+                $valuesArray = array();
+                foreach ($hits as $hit) {
+                    try {
+                        $value = $hit->getDocument()->getFieldValue($fieldName);
+                    } catch (Zend_Search_Lucene_Exception $e) {
+                        if (strpos($e->getMessage(), 'not found') === false) {
+                            throw $e;
+                        } else {
+                            $value = null;
+                        }
+                    }
+
+                    $valuesArray[] = $value;
+                }
+
+                $sortArgs[] = &$valuesArray;
+
+                if ($count + 1 < count($argList)  &&  is_integer($argList[$count+1])) {
+                    $count++;
+                    $sortArgs[] = &$argList[$count];
+
+                    if ($count + 1 < count($argList)  &&  is_integer($argList[$count+1])) {
+                        $count++;
+                        $sortArgs[] = &$argList[$count];
+                    } else {
+                        if ($argList[$count] == SORT_ASC  || $argList[$count] == SORT_DESC) {
+                            $sortArgs[] = &$sortReg;
+                        } else {
+                            $sortArgs[] = &$sortAsc;
+                        }
+                    }
+                } else {
+                    $sortArgs[] = &$sortAsc;
+                    $sortArgs[] = &$sortReg;
+                }
+            }
+
+            // Sort by id's if values are equal
+            $sortArgs[] = &$ids;
+            $sortArgs[] = &$sortAsc;
+            $sortArgs[] = &$sortNum;
+
+            // Array to be sorted
+            $sortArgs[] = &$hits;
+
+            // Do sort
+            call_user_func_array('array_multisort', $sortArgs);
+        }
+
+        return $hits;
+    }
+
+
+    /**
+     * Returns a list of all unique field names that exist in this index.
+     *
+     * @param boolean $indexed
+     * @return array
+     */
+    public function getFieldNames($indexed = false)
+    {
+        $result = array();
+        foreach( $this->_segmentInfos as $segmentInfo ) {
+            $result = array_merge($result, $segmentInfo->getFields($indexed));
+        }
+        return $result;
+    }
+
+
+    /**
+     * Returns a Zend_Search_Lucene_Document object for the document
+     * number $id in this index.
+     *
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id
+     * @return Zend_Search_Lucene_Document
+     * @throws Zend_Search_Lucene_Exception    Exception is thrown if $id is out of the range
+     */
+    public function getDocument($id)
+    {
+        if ($id instanceof Zend_Search_Lucene_Search_QueryHit) {
+            /* @var $id Zend_Search_Lucene_Search_QueryHit */
+            $id = $id->id;
+        }
+
+        if ($id >= $this->_docCount) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Document id is out of the range.');
+        }
+
+        $segmentStartId = 0;
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            if ($segmentStartId + $segmentInfo->count() > $id) {
+                break;
+            }
+
+            $segmentStartId += $segmentInfo->count();
+        }
+
+        $fdxFile = $segmentInfo->openCompoundFile('.fdx');
+        $fdxFile->seek(($id-$segmentStartId)*8, SEEK_CUR);
+        $fieldValuesPosition = $fdxFile->readLong();
+
+        $fdtFile = $segmentInfo->openCompoundFile('.fdt');
+        $fdtFile->seek($fieldValuesPosition, SEEK_CUR);
+        $fieldCount = $fdtFile->readVInt();
+
+        $doc = new Zend_Search_Lucene_Document();
+        for ($count = 0; $count < $fieldCount; $count++) {
+            $fieldNum = $fdtFile->readVInt();
+            $bits = $fdtFile->readByte();
+
+            $fieldInfo = $segmentInfo->getField($fieldNum);
+
+            if (!($bits & 2)) { // Text data
+                $field = new Zend_Search_Lucene_Field($fieldInfo->name,
+                                                      $fdtFile->readString(),
+                                                      'UTF-8',
+                                                      true,
+                                                      $fieldInfo->isIndexed,
+                                                      $bits & 1 );
+            } else {            // Binary data
+                $field = new Zend_Search_Lucene_Field($fieldInfo->name,
+                                                      $fdtFile->readBinary(),
+                                                      '',
+                                                      true,
+                                                      $fieldInfo->isIndexed,
+                                                      $bits & 1,
+                                                      true );
+            }
+
+            $doc->addField($field);
+        }
+
+        return $doc;
+    }
+
+
+    /**
+     * Returns true if index contain documents with specified term.
+     *
+     * Is used for query optimization.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @return boolean
+     */
+    public function hasTerm(Zend_Search_Lucene_Index_Term $term)
+    {
+        foreach ($this->_segmentInfos as $segInfo) {
+            if ($segInfo->getTermInfo($term) instanceof Zend_Search_Lucene_Index_TermInfo) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns IDs of all documents containing term.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return array
+     */
+    public function termDocs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        $subResults = array();
+        $segmentStartDocId = 0;
+
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            $subResults[] = $segmentInfo->termDocs($term, $segmentStartDocId, $docsFilter);
+
+            $segmentStartDocId += $segmentInfo->count();
+        }
+
+        if (count($subResults) == 0) {
+            return array();
+        } else if (count($subResults) == 0) {
+            // Index is optimized (only one segment)
+            // Do not perform array reindexing
+            return reset($subResults);
+        } else {
+            $result = call_user_func_array('array_merge', $subResults);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Returns documents filter for all documents containing term.
+     *
+     * It performs the same operation as termDocs, but return result as
+     * Zend_Search_Lucene_Index_DocsFilter object
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return Zend_Search_Lucene_Index_DocsFilter
+     */
+    public function termDocsFilter(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        $segmentStartDocId = 0;
+        $result = new Zend_Search_Lucene_Index_DocsFilter();
+
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            $subResults[] = $segmentInfo->termDocs($term, $segmentStartDocId, $docsFilter);
+
+            $segmentStartDocId += $segmentInfo->count();
+        }
+
+        if (count($subResults) == 0) {
+            return array();
+        } else if (count($subResults) == 0) {
+            // Index is optimized (only one segment)
+            // Do not perform array reindexing
+            return reset($subResults);
+        } else {
+            $result = call_user_func_array('array_merge', $subResults);
+        }
+
+        return $result;
+    }
+
+
+    /**
+     * Returns an array of all term freqs.
+     * Result array structure: array(docId => freq, ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return integer
+     */
+    public function termFreqs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        $result = array();
+        $segmentStartDocId = 0;
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            $result += $segmentInfo->termFreqs($term, $segmentStartDocId, $docsFilter);
+
+            $segmentStartDocId += $segmentInfo->count();
+        }
+
+        return $result;
+    }
+
+    /**
+     * Returns an array of all term positions in the documents.
+     * Result array structure: array(docId => array(pos1, pos2, ...), ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return array
+     */
+    public function termPositions(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        $result = array();
+        $segmentStartDocId = 0;
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            $result += $segmentInfo->termPositions($term, $segmentStartDocId, $docsFilter);
+
+            $segmentStartDocId += $segmentInfo->count();
+        }
+
+        return $result;
+    }
+
+
+    /**
+     * Returns the number of documents in this index containing the $term.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @return integer
+     */
+    public function docFreq(Zend_Search_Lucene_Index_Term $term)
+    {
+        $result = 0;
+        foreach ($this->_segmentInfos as $segInfo) {
+            $termInfo = $segInfo->getTermInfo($term);
+            if ($termInfo !== null) {
+                $result += $termInfo->docFreq;
+            }
+        }
+
+        return $result;
+    }
+
+
+    /**
+     * Retrive similarity used by index reader
+     *
+     * @return Zend_Search_Lucene_Search_Similarity
+     */
+    public function getSimilarity()
+    {
+        return Zend_Search_Lucene_Search_Similarity::getDefault();
+    }
+
+
+    /**
+     * Returns a normalization factor for "field, document" pair.
+     *
+     * @param integer $id
+     * @param string $fieldName
+     * @return float
+     */
+    public function norm($id, $fieldName)
+    {
+        if ($id >= $this->_docCount) {
+            return null;
+        }
+
+        $segmentStartId = 0;
+        foreach ($this->_segmentInfos as $segInfo) {
+            if ($segmentStartId + $segInfo->count() > $id) {
+                break;
+            }
+
+            $segmentStartId += $segInfo->count();
+        }
+
+        if ($segInfo->isDeleted($id - $segmentStartId)) {
+            return 0;
+        }
+
+        return $segInfo->norm($id - $segmentStartId, $fieldName);
+    }
+
+    /**
+     * Returns true if any documents have been deleted from this index.
+     *
+     * @return boolean
+     */
+    public function hasDeletions()
+    {
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            if ($segmentInfo->hasDeletions()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+
+    /**
+     * Deletes a document from the index.
+     * $id is an internal document id
+     *
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function delete($id)
+    {
+        if ($id instanceof Zend_Search_Lucene_Search_QueryHit) {
+            /* @var $id Zend_Search_Lucene_Search_QueryHit */
+            $id = $id->id;
+        }
+
+        if ($id >= $this->_docCount) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Document id is out of the range.');
+        }
+
+        $segmentStartId = 0;
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            if ($segmentStartId + $segmentInfo->count() > $id) {
+                break;
+            }
+
+            $segmentStartId += $segmentInfo->count();
+        }
+        $segmentInfo->delete($id - $segmentStartId);
+
+        $this->_hasChanges = true;
+    }
+
+
+
+    /**
+     * Adds a document to this index.
+     *
+     * @param Zend_Search_Lucene_Document $document
+     */
+    public function addDocument(Zend_Search_Lucene_Document $document)
+    {
+        $this->_getIndexWriter()->addDocument($document);
+        $this->_docCount++;
+
+        $this->_hasChanges = true;
+    }
+
+
+    /**
+     * Update document counter
+     */
+    private function _updateDocCount()
+    {
+        $this->_docCount = 0;
+        foreach ($this->_segmentInfos as $segInfo) {
+            $this->_docCount += $segInfo->count();
+        }
+    }
+
+    /**
+     * Commit changes resulting from delete() or undeleteAll() operations.
+     *
+     * @todo undeleteAll processing.
+     */
+    public function commit()
+    {
+        if ($this->_hasChanges) {
+            $this->_getIndexWriter()->commit();
+
+            $this->_updateDocCount();
+
+            $this->_hasChanges = false;
+        }
+    }
+
+
+    /**
+     * Optimize index.
+     *
+     * Merges all segments into one
+     */
+    public function optimize()
+    {
+        // Commit changes if any changes have been made
+        $this->commit();
+
+        if (count($this->_segmentInfos) > 1 || $this->hasDeletions()) {
+            $this->_getIndexWriter()->optimize();
+            $this->_updateDocCount();
+        }
+    }
+
+
+    /**
+     * Returns an array of all terms in this index.
+     *
+     * @return array
+     */
+    public function terms()
+    {
+        $result = array();
+
+        $segmentInfoQueue = new Zend_Search_Lucene_Index_TermsPriorityQueue();
+
+        foreach ($this->_segmentInfos as $segmentInfo) {
+            $segmentInfo->resetTermsStream();
+
+            // Skip "empty" segments
+            if ($segmentInfo->currentTerm() !== null) {
+                $segmentInfoQueue->put($segmentInfo);
+            }
+        }
+
+        while (($segmentInfo = $segmentInfoQueue->pop()) !== null) {
+            if ($segmentInfoQueue->top() === null ||
+                $segmentInfoQueue->top()->currentTerm()->key() !=
+                            $segmentInfo->currentTerm()->key()) {
+                // We got new term
+                $result[] = $segmentInfo->currentTerm();
+            }
+
+            if ($segmentInfo->nextTerm() !== null) {
+                // Put segment back into the priority queue
+                $segmentInfoQueue->put($segmentInfo);
+            }
+        }
+
+        return $result;
+    }
+
+
+    /**
+     * Terms stream priority queue object
+     *
+     * @var Zend_Search_Lucene_TermStreamsPriorityQueue
+     */
+    private $_termsStream = null;
+
+    /**
+     * Reset terms stream.
+     */
+    public function resetTermsStream()
+    {
+       if ($this->_termsStream === null) {
+            $this->_termsStream = new Zend_Search_Lucene_TermStreamsPriorityQueue($this->_segmentInfos);
+       } else {
+               $this->_termsStream->resetTermsStream();
+       }
+    }
+
+    /**
+     * Skip terms stream up to specified term preffix.
+     *
+     * Prefix contains fully specified field info and portion of searched term
+     *
+     * @param Zend_Search_Lucene_Index_Term $prefix
+     */
+    public function skipTo(Zend_Search_Lucene_Index_Term $prefix)
+    {
+        $this->_termsStream->skipTo($prefix);
+    }
+
+    /**
+     * Scans terms dictionary and returns next term
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function nextTerm()
+    {
+        return $this->_termsStream->nextTerm();
+    }
+
+    /**
+     * Returns term in current position
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function currentTerm()
+    {
+        return $this->_termsStream->currentTerm();
+    }
+
+    /**
+     * Close terms stream
+     *
+     * Should be used for resources clean up if stream is not read up to the end
+     */
+    public function closeTermsStream()
+    {
+        $this->_termsStream->closeTermsStream();
+        $this->_termsStream = null;
+    }
+
+
+    /*************************************************************************
+    @todo UNIMPLEMENTED
+    *************************************************************************/
+    /**
+     * Undeletes all documents currently marked as deleted in this index.
+     *
+     * @todo Implementation
+     */
+    public function undeleteAll()
+    {}
+}
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer.php
new file mode 100644 (file)
index 0000000..171f2b2
--- /dev/null
@@ -0,0 +1,177 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Analyzer.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Token */
+require_once 'Zend/Search/Lucene/Analysis/Token.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8 */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8_CaseInsensitive */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8Num */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8Num_CaseInsensitive */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Text */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Text_CaseInsensitive */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_TextNum */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_TextNum_CaseInsensitive */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php';
+
+/** Zend_Search_Lucene_Analysis_TokenFilter_StopWords */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php';
+
+/** Zend_Search_Lucene_Analysis_TokenFilter_ShortWords */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php';
+
+
+/**
+ * An Analyzer is used to analyze text.
+ * It thus represents a policy for extracting index terms from text.
+ *
+ * Note:
+ * Lucene Java implementation is oriented to streams. It provides effective work
+ * with a huge documents (more then 20Mb).
+ * But engine itself is not oriented such documents.
+ * Thus Zend_Search_Lucene analysis API works with data strings and sets (arrays).
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+abstract class Zend_Search_Lucene_Analysis_Analyzer
+{
+    /**
+     * The Analyzer implementation used by default.
+     *
+     * @var Zend_Search_Lucene_Analysis_Analyzer
+     */
+    private static $_defaultImpl;
+
+    /**
+     * Input string
+     *
+     * @var string
+     */
+    protected $_input = null;
+
+    /**
+     * Input string encoding
+     *
+     * @var string
+     */
+    protected $_encoding = '';
+
+    /**
+     * Tokenize text to a terms
+     * Returns array of Zend_Search_Lucene_Analysis_Token objects
+     *
+     * Tokens are returned in UTF-8 (internal Zend_Search_Lucene encoding)
+     *
+     * @param string $data
+     * @return array
+     */
+    public function tokenize($data, $encoding = '')
+    {
+        $this->setInput($data, $encoding);
+
+        $tokenList = array();
+        while (($nextToken = $this->nextToken()) !== null) {
+            $tokenList[] = $nextToken;
+        }
+
+        return $tokenList;
+    }
+
+
+    /**
+     * Tokenization stream API
+     * Set input
+     *
+     * @param string $data
+     */
+    public function setInput($data, $encoding = '')
+    {
+        $this->_input    = $data;
+        $this->_encoding = $encoding;
+        $this->reset();
+    }
+
+    /**
+     * Reset token stream
+     */
+    abstract public function reset();
+
+    /**
+     * Tokenization stream API
+     * Get next token
+     * Returns null at the end of stream
+     *
+     * Tokens are returned in UTF-8 (internal Zend_Search_Lucene encoding)
+     *
+     * @return Zend_Search_Lucene_Analysis_Token|null
+     */
+    abstract public function nextToken();
+
+
+
+
+    /**
+     * Set the default Analyzer implementation used by indexing code.
+     *
+     * @param Zend_Search_Lucene_Analysis_Analyzer $similarity
+     */
+    public static function setDefault(Zend_Search_Lucene_Analysis_Analyzer $analyzer)
+    {
+        self::$_defaultImpl = $analyzer;
+    }
+
+
+    /**
+     * Return the default Analyzer implementation used by indexing code.
+     *
+     * @return Zend_Search_Lucene_Analysis_Analyzer
+     */
+    public static function getDefault()
+    {
+        if (!self::$_defaultImpl instanceof Zend_Search_Lucene_Analysis_Analyzer) {
+            self::$_defaultImpl = new Zend_Search_Lucene_Analysis_Analyzer_Common_Text_CaseInsensitive();
+        }
+
+        return self::$_defaultImpl;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common.php
new file mode 100644 (file)
index 0000000..de63cdb
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Common.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer.php';
+
+
+/**
+ * Common implementation of the Zend_Search_Lucene_Analysis_Analyzer interface.
+ * There are several standard standard subclasses provided by Zend_Search_Lucene/Analysis
+ * subpackage: Zend_Search_Lucene_Analysis_Analyzer_Common_Text, ZSearchHTMLAnalyzer, ZSearchXMLAnalyzer.
+ *
+ * @todo ZSearchHTMLAnalyzer and ZSearchXMLAnalyzer implementation
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Analysis_Analyzer_Common extends Zend_Search_Lucene_Analysis_Analyzer
+{
+    /**
+     * The set of Token filters applied to the Token stream.
+     * Array of Zend_Search_Lucene_Analysis_TokenFilter objects.
+     *
+     * @var array
+     */
+    private $_filters = array();
+
+    /**
+     * Add Token filter to the Analyzer
+     *
+     * @param Zend_Search_Lucene_Analysis_TokenFilter $filter
+     */
+    public function addFilter(Zend_Search_Lucene_Analysis_TokenFilter $filter)
+    {
+        $this->_filters[] = $filter;
+    }
+
+    /**
+     * Apply filters to the token. Can return null when the token was removed.
+     *
+     * @param Zend_Search_Lucene_Analysis_Token $token
+     * @return Zend_Search_Lucene_Analysis_Token
+     */
+    public function normalize(Zend_Search_Lucene_Analysis_Token $token)
+    {
+        foreach ($this->_filters as $filter) {
+            $token = $filter->normalize($token);
+
+            // resulting token can be null if the filter removes it
+            if ($token === null) {
+                return null;
+            }
+        }
+
+        return $token;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php
new file mode 100644 (file)
index 0000000..a9bf3d0
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Text.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_Text extends Zend_Search_Lucene_Analysis_Analyzer_Common
+{
+    /**
+     * Current position in a stream
+     *
+     * @var integer
+     */
+    private $_position;
+
+    /**
+     * Reset token stream
+     */
+    public function reset()
+    {
+        $this->_position = 0;
+
+        if ($this->_input === null) {
+            return;
+        }
+
+        // convert input into ascii
+        if (PHP_OS != 'AIX') {
+            $this->_input = iconv($this->_encoding, 'ASCII//TRANSLIT', $this->_input);
+        }
+        $this->_encoding = 'ASCII';
+    }
+
+    /**
+     * Tokenization stream API
+     * Get next token
+     * Returns null at the end of stream
+     *
+     * @return Zend_Search_Lucene_Analysis_Token|null
+     */
+    public function nextToken()
+    {
+        if ($this->_input === null) {
+            return null;
+        }
+
+
+        do {
+            if (! preg_match('/[a-zA-Z]+/', $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_position)) {
+                // It covers both cases a) there are no matches (preg_match(...) === 0)
+                // b) error occured (preg_match(...) === FALSE)
+                return null;
+            }
+
+            $str = $match[0][0];
+            $pos = $match[0][1];
+            $endpos = $pos + strlen($str);
+
+            $this->_position = $endpos;
+
+            $token = $this->normalize(new Zend_Search_Lucene_Analysis_Token($str, $pos, $endpos));
+        } while ($token === null); // try again if token is skipped
+
+        return $token;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php
new file mode 100644 (file)
index 0000000..3267b4b
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: CaseInsensitive.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Text */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php';
+
+/** Zend_Search_Lucene_Analysis_TokenFilter_LowerCase */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter/LowerCase.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_Text_CaseInsensitive extends Zend_Search_Lucene_Analysis_Analyzer_Common_Text
+{
+    public function __construct()
+    {
+        $this->addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCase());
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php
new file mode 100644 (file)
index 0000000..b2e99de
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: TextNum.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_TextNum extends Zend_Search_Lucene_Analysis_Analyzer_Common
+{
+    /**
+     * Current position in a stream
+     *
+     * @var integer
+     */
+    private $_position;
+
+    /**
+     * Reset token stream
+     */
+    public function reset()
+    {
+        $this->_position = 0;
+
+        if ($this->_input === null) {
+            return;
+        }
+
+        // convert input into ascii
+        if (PHP_OS != 'AIX') {
+            $this->_input = iconv($this->_encoding, 'ASCII//TRANSLIT', $this->_input);
+        }
+        $this->_encoding = 'ASCII';
+    }
+
+    /**
+     * Tokenization stream API
+     * Get next token
+     * Returns null at the end of stream
+     *
+     * @return Zend_Search_Lucene_Analysis_Token|null
+     */
+    public function nextToken()
+    {
+        if ($this->_input === null) {
+            return null;
+        }
+
+        do {
+            if (! preg_match('/[a-zA-Z0-9]+/', $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_position)) {
+                // It covers both cases a) there are no matches (preg_match(...) === 0)
+                // b) error occured (preg_match(...) === FALSE)
+                return null;
+            }
+
+            $str = $match[0][0];
+            $pos = $match[0][1];
+            $endpos = $pos + strlen($str);
+
+            $this->_position = $endpos;
+
+            $token = $this->normalize(new Zend_Search_Lucene_Analysis_Token($str, $pos, $endpos));
+        } while ($token === null); // try again if token is skipped
+
+        return $token;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php
new file mode 100644 (file)
index 0000000..f37fa44
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: CaseInsensitive.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_TextNum */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php';
+
+/** Zend_Search_Lucene_Analysis_TokenFilter_LowerCase */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter/LowerCase.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_TextNum_CaseInsensitive extends Zend_Search_Lucene_Analysis_Analyzer_Common_TextNum
+{
+    public function __construct()
+    {
+        $this->addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCase());
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php
new file mode 100644 (file)
index 0000000..0a8237f
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Utf8.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8 extends Zend_Search_Lucene_Analysis_Analyzer_Common
+{
+    /**
+     * Current char position in an UTF-8 stream
+     *
+     * @var integer
+     */
+    private $_position;
+
+    /**
+     * Current binary position in an UTF-8 stream
+     *
+     * @var integer
+     */
+    private $_bytePosition;
+    
+    /**
+     * Object constructor
+     *
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct()
+    {
+        if (@preg_match('/\pL/u', 'a') != 1) {
+            // PCRE unicode support is turned off
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Utf8 analyzer needs PCRE unicode support to be enabled.');
+        }
+    }
+
+    /**
+     * Reset token stream
+     */
+    public function reset()
+    {
+        $this->_position     = 0;
+        $this->_bytePosition = 0;
+
+        // convert input into UTF-8
+        if (strcasecmp($this->_encoding, 'utf8' ) != 0  &&
+            strcasecmp($this->_encoding, 'utf-8') != 0 ) {
+                $this->_input = iconv($this->_encoding, 'UTF-8', $this->_input);
+                $this->_encoding = 'UTF-8';
+        }
+    }
+
+    /**
+     * Tokenization stream API
+     * Get next token
+     * Returns null at the end of stream
+     *
+     * @return Zend_Search_Lucene_Analysis_Token|null
+     */
+    public function nextToken()
+    {
+        if ($this->_input === null) {
+            return null;
+        }
+
+        do {
+            if (! preg_match('/[\p{L}]+/u', $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_bytePosition)) {
+                // It covers both cases a) there are no matches (preg_match(...) === 0)
+                // b) error occured (preg_match(...) === FALSE)
+                return null;
+            }
+
+            // matched string
+            $matchedWord = $match[0][0];
+            
+            // binary position of the matched word in the input stream
+            $binStartPos = $match[0][1];
+            
+            // character position of the matched word in the input stream
+            $startPos = $this->_position + 
+                        iconv_strlen(substr($this->_input,
+                                            $this->_bytePosition,
+                                            $binStartPos - $this->_bytePosition),
+                                     'UTF-8');
+            // character postion of the end of matched word in the input stream
+            $endPos = $startPos + iconv_strlen($matchedWord, 'UTF-8');
+
+            $this->_bytePosition = $binStartPos + strlen($matchedWord);
+            $this->_position     = $endPos;
+
+            $token = $this->normalize(new Zend_Search_Lucene_Analysis_Token($matchedWord, $startPos, $endPos));
+        } while ($token === null); // try again if token is skipped
+
+        return $token;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php
new file mode 100644 (file)
index 0000000..dfc8651
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: CaseInsensitive.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8 */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php';
+
+/** Zend_Search_Lucene_Analysis_TokenFilter_LowerCaseUtf8 */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8_CaseInsensitive extends Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8 
+{
+    public function __construct()
+    {
+        parent::__construct();
+
+        $this->addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCaseUtf8());
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php
new file mode 100644 (file)
index 0000000..b39cc40
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Utf8Num.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8Num extends Zend_Search_Lucene_Analysis_Analyzer_Common
+{
+    /**
+     * Current char position in an UTF-8 stream
+     *
+     * @var integer
+     */
+    private $_position;
+
+    /**
+     * Current binary position in an UTF-8 stream
+     *
+     * @var integer
+     */
+    private $_bytePosition;
+
+    /**
+     * Object constructor
+     *
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct()
+    {
+        if (@preg_match('/\pL/u', 'a') != 1) {
+            // PCRE unicode support is turned off
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Utf8Num analyzer needs PCRE unicode support to be enabled.');
+        }
+    }
+
+    /**
+     * Reset token stream
+     */
+    public function reset()
+    {
+        $this->_position     = 0;
+        $this->_bytePosition = 0;
+
+        // convert input into UTF-8
+        if (strcasecmp($this->_encoding, 'utf8' ) != 0  &&
+            strcasecmp($this->_encoding, 'utf-8') != 0 ) {
+                $this->_input = iconv($this->_encoding, 'UTF-8', $this->_input);
+                $this->_encoding = 'UTF-8';
+        }
+    }
+
+    /**
+     * Tokenization stream API
+     * Get next token
+     * Returns null at the end of stream
+     *
+     * @return Zend_Search_Lucene_Analysis_Token|null
+     */
+    public function nextToken()
+    {
+        if ($this->_input === null) {
+            return null;
+        }
+
+        do {
+            if (! preg_match('/[\p{L}\p{N}]+/u', $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_bytePosition)) {
+                // It covers both cases a) there are no matches (preg_match(...) === 0)
+                // b) error occured (preg_match(...) === FALSE)
+                return null;
+            }
+
+            // matched string
+            $matchedWord = $match[0][0];
+            
+            // binary position of the matched word in the input stream
+            $binStartPos = $match[0][1];
+            
+            // character position of the matched word in the input stream
+            $startPos = $this->_position + 
+                        iconv_strlen(substr($this->_input,
+                                            $this->_bytePosition,
+                                            $binStartPos - $this->_bytePosition),
+                                     'UTF-8');
+            // character postion of the end of matched word in the input stream
+            $endPos = $startPos + iconv_strlen($matchedWord, 'UTF-8');
+
+            $this->_bytePosition = $binStartPos + strlen($matchedWord);
+            $this->_position     = $endPos;
+
+            $token = $this->normalize(new Zend_Search_Lucene_Analysis_Token($matchedWord, $startPos, $endPos));
+        } while ($token === null); // try again if token is skipped
+
+        return $token;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php b/inc/lib/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php
new file mode 100644 (file)
index 0000000..092a0c6
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: CaseInsensitive.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8Num */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php';
+
+/** Zend_Search_Lucene_Analysis_TokenFilter_LowerCaseUtf8 */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+
+class Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8Num_CaseInsensitive extends Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8Num
+{
+    public function __construct()
+    {
+        parent::__construct();
+
+        $this->addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCaseUtf8());
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/Token.php b/inc/lib/Zend/Search/Lucene/Analysis/Token.php
new file mode 100644 (file)
index 0000000..dbe3da0
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Token.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Analysis_Token
+{
+    /**
+     * The text of the term.
+     *
+     * @var string
+     */
+    private $_termText;
+
+    /**
+     * Start in source text.
+     *
+     * @var integer
+     */
+    private $_startOffset;
+
+    /**
+     * End in source text
+     *
+     * @var integer
+     */
+    private $_endOffset;
+
+    /**
+     * The position of this token relative to the previous Token.
+     *
+     * The default value is one.
+     *
+     * Some common uses for this are:
+     * Set it to zero to put multiple terms in the same position.  This is
+     * useful if, e.g., a word has multiple stems.  Searches for phrases
+     * including either stem will match.  In this case, all but the first stem's
+     * increment should be set to zero: the increment of the first instance
+     * should be one.  Repeating a token with an increment of zero can also be
+     * used to boost the scores of matches on that token.
+     *
+     * Set it to values greater than one to inhibit exact phrase matches.
+     * If, for example, one does not want phrases to match across removed stop
+     * words, then one could build a stop word filter that removes stop words and
+     * also sets the increment to the number of stop words removed before each
+     * non-stop word.  Then exact phrase queries will only match when the terms
+     * occur with no intervening stop words.
+     *
+     * @var integer
+     */
+    private $_positionIncrement;
+
+
+    /**
+     * Object constructor
+     *
+     * @param string  $text
+     * @param integer $start
+     * @param integer $end
+     * @param string  $type
+     */
+    public function __construct($text, $start, $end)
+    {
+        $this->_termText    = $text;
+        $this->_startOffset = $start;
+        $this->_endOffset   = $end;
+
+        $this->_positionIncrement = 1;
+    }
+
+
+    /**
+     * positionIncrement setter
+     *
+     * @param integer $positionIncrement
+     */
+    public function setPositionIncrement($positionIncrement)
+    {
+        $this->_positionIncrement = $positionIncrement;
+    }
+
+    /**
+     * Returns the position increment of this Token.
+     *
+     * @return integer
+     */
+    public function getPositionIncrement()
+    {
+        return $this->_positionIncrement;
+    }
+
+    /**
+     * Returns the Token's term text.
+     *
+     * @return string
+     */
+    public function getTermText()
+    {
+        return $this->_termText;
+    }
+
+    /**
+     * Returns this Token's starting offset, the position of the first character
+     * corresponding to this token in the source text.
+     *
+     * Note:
+     * The difference between getEndOffset() and getStartOffset() may not be equal
+     * to strlen(Zend_Search_Lucene_Analysis_Token::getTermText()), as the term text may have been altered
+     * by a stemmer or some other filter.
+     *
+     * @return integer
+     */
+    public function getStartOffset()
+    {
+        return $this->_startOffset;
+    }
+
+    /**
+     * Returns this Token's ending offset, one greater than the position of the
+     * last character corresponding to this token in the source text.
+     *
+     * @return integer
+     */
+    public function getEndOffset()
+    {
+        return $this->_endOffset;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter.php b/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter.php
new file mode 100644 (file)
index 0000000..895b400
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: TokenFilter.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_Token */
+require_once 'Zend/Search/Lucene/Analysis/Token.php';
+
+
+/**
+ * Token filter converts (normalizes) Token ore removes it from a token stream.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+abstract class Zend_Search_Lucene_Analysis_TokenFilter
+{
+    /**
+     * Normalize Token or remove it (if null is returned)
+     *
+     * @param Zend_Search_Lucene_Analysis_Token $srcToken
+     * @return Zend_Search_Lucene_Analysis_Token
+     */
+    abstract public function normalize(Zend_Search_Lucene_Analysis_Token $srcToken);
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/LowerCase.php b/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/LowerCase.php
new file mode 100644 (file)
index 0000000..c272107
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: LowerCase.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_TokenFilter */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter.php';
+
+
+/**
+ * Lower case Token filter.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_TokenFilter_LowerCase extends Zend_Search_Lucene_Analysis_TokenFilter
+{
+    /**
+     * Normalize Token or remove it (if null is returned)
+     *
+     * @param Zend_Search_Lucene_Analysis_Token $srcToken
+     * @return Zend_Search_Lucene_Analysis_Token
+     */
+    public function normalize(Zend_Search_Lucene_Analysis_Token $srcToken)
+    {
+        $newToken = new Zend_Search_Lucene_Analysis_Token(
+                                     strtolower( $srcToken->getTermText() ),
+                                     $srcToken->getStartOffset(),
+                                     $srcToken->getEndOffset());
+
+        $newToken->setPositionIncrement($srcToken->getPositionIncrement());
+
+        return $newToken;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php b/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php
new file mode 100644 (file)
index 0000000..7f9bbb2
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: LowerCaseUtf8.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_TokenFilter */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter.php';
+
+
+/**
+ * Lower case Token filter.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_TokenFilter_LowerCaseUtf8 extends Zend_Search_Lucene_Analysis_TokenFilter
+{
+    /**
+     * Object constructor
+     */
+    public function __construct()
+    {
+        if (!function_exists('mb_strtolower')) {
+            // mbstring extension is disabled
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Utf8 compatible lower case filter needs mbstring extension to be enabled.');
+        }
+    }
+    
+    /**
+     * Normalize Token or remove it (if null is returned)
+     *
+     * @param Zend_Search_Lucene_Analysis_Token $srcToken
+     * @return Zend_Search_Lucene_Analysis_Token
+     */
+    public function normalize(Zend_Search_Lucene_Analysis_Token $srcToken)
+    {
+        $newToken = new Zend_Search_Lucene_Analysis_Token(
+                                     mb_strtolower($srcToken->getTermText(), 'UTF-8'),
+                                     $srcToken->getStartOffset(),
+                                     $srcToken->getEndOffset());
+
+        $newToken->setPositionIncrement($srcToken->getPositionIncrement());
+
+        return $newToken;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php b/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php
new file mode 100644 (file)
index 0000000..04e2d48
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: ShortWords.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Analysis_TokenFilter */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter.php';
+
+
+/**
+ * Token filter that removes short words. What is short word can be configured with constructor.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_TokenFilter_ShortWords extends Zend_Search_Lucene_Analysis_TokenFilter
+{
+    /**
+     * Minimum allowed term length
+     * @var integer
+     */
+    private $length;
+
+    /**
+     * Constructs new instance of this filter.
+     *
+     * @param integer $short  minimum allowed length of term which passes this filter (default 2)
+     */
+    public function __construct($length = 2) {
+        $this->length = $length;
+    }
+
+    /**
+     * Normalize Token or remove it (if null is returned)
+     *
+     * @param Zend_Search_Lucene_Analysis_Token $srcToken
+     * @return Zend_Search_Lucene_Analysis_Token
+     */
+    public function normalize(Zend_Search_Lucene_Analysis_Token $srcToken) {
+        if (strlen($srcToken->getTermText()) < $this->length) {
+            return null;
+        } else {
+            return $srcToken;
+        }
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php b/inc/lib/Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php
new file mode 100644 (file)
index 0000000..50379c3
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: StopWords.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Analysis_TokenFilter */
+require_once 'Zend/Search/Lucene/Analysis/TokenFilter.php';
+
+/**
+ * Token filter that removes stop words. These words must be provided as array (set), example:
+ * $stopwords = array('the' => 1, 'an' => '1');
+ *
+ * We do recommend to provide all words in lowercase and concatenate this class after the lowercase filter.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Analysis
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+
+class Zend_Search_Lucene_Analysis_TokenFilter_StopWords extends Zend_Search_Lucene_Analysis_TokenFilter
+{
+    /**
+     * Stop Words
+     * @var array
+     */
+    private $_stopSet;
+
+    /**
+     * Constructs new instance of this filter.
+     *
+     * @param array $stopwords array (set) of words that will be filtered out
+     */
+    public function __construct($stopwords = array()) {
+        $this->_stopSet = array_flip($stopwords);
+    }
+
+    /**
+     * Normalize Token or remove it (if null is returned)
+     *
+     * @param Zend_Search_Lucene_Analysis_Token $srcToken
+     * @return Zend_Search_Lucene_Analysis_Token
+     */
+    public function normalize(Zend_Search_Lucene_Analysis_Token $srcToken) {
+        if (array_key_exists($srcToken->getTermText(), $this->_stopSet)) {
+            return null;
+        } else {
+            return $srcToken;
+        }
+    }
+
+    /**
+     * Fills stopwords set from a text file. Each line contains one stopword, lines with '#' in the first
+     * column are ignored (as comments).
+     *
+     * You can call this method one or more times. New stopwords are always added to current set.
+     *
+     * @param string $filepath full path for text file with stopwords
+     * @throws Zend_Search_Exception When the file doesn`t exists or is not readable.
+     */
+    public function loadFromFile($filepath = null) {
+        if (! $filepath || ! file_exists($filepath)) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('You have to provide valid file path');
+        }
+        $fd = fopen($filepath, "r");
+        if (! $fd) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Cannot open file ' . $filepath);
+        }
+        while (!feof ($fd)) {
+            $buffer = trim(fgets($fd));
+            if (strlen($buffer) > 0 && $buffer[0] != '#') {
+                $this->_stopSet[$buffer] = 1;
+            }
+        }
+        if (!fclose($fd)) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Cannot close file ' . $filepath);
+        }
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Document.php b/inc/lib/Zend/Search/Lucene/Document.php
new file mode 100644 (file)
index 0000000..499d9b4
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Document.php 16543 2009-07-07 09:44:56Z yoshida@zend.co.jp $
+ */
+
+
+/** Zend_Search_Lucene_Field */
+require_once 'Zend/Search/Lucene/Field.php';
+
+
+/**
+ * A Document is a set of fields. Each field has a name and a textual value.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Document
+{
+
+    /**
+     * Associative array Zend_Search_Lucene_Field objects where the keys to the
+     * array are the names of the fields.
+     *
+     * @var array
+     */
+    protected $_fields = array();
+
+    /**
+     * Field boost factor
+     * It's not stored directly in the index, but affects on normalization factor
+     *
+     * @var float
+     */
+    public $boost = 1.0;
+
+    /**
+     * Proxy method for getFieldValue(), provides more convenient access to
+     * the string value of a field.
+     *
+     * @param  $offset
+     * @return string
+     */
+    public function __get($offset)
+    {
+        return $this->getFieldValue($offset);
+    }
+
+
+    /**
+     * Add a field object to this document.
+     *
+     * @param Zend_Search_Lucene_Field $field
+     * @return Zend_Search_Lucene_Document
+     */
+    public function addField(Zend_Search_Lucene_Field $field)
+    {
+        $this->_fields[$field->name] = $field;
+
+        return $this;
+    }
+
+
+    /**
+     * Return an array with the names of the fields in this document.
+     *
+     * @return array
+     */
+    public function getFieldNames()
+    {
+        return array_keys($this->_fields);
+    }
+
+
+    /**
+     * Returns Zend_Search_Lucene_Field object for a named field in this document.
+     *
+     * @param string $fieldName
+     * @return Zend_Search_Lucene_Field
+     */
+    public function getField($fieldName)
+    {
+        if (!array_key_exists($fieldName, $this->_fields)) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception("Field name \"$fieldName\" not found in document.");
+        }
+        return $this->_fields[$fieldName];
+    }
+
+
+    /**
+     * Returns the string value of a named field in this document.
+     *
+     * @see __get()
+     * @return string
+     */
+    public function getFieldValue($fieldName)
+    {
+        return $this->getField($fieldName)->value;
+    }
+
+    /**
+     * Returns the string value of a named field in UTF-8 encoding.
+     *
+     * @see __get()
+     * @return string
+     */
+    public function getFieldUtf8Value($fieldName)
+    {
+        return $this->getField($fieldName)->getUtf8Value();
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Document/Docx.php b/inc/lib/Zend/Search/Lucene/Document/Docx.php
new file mode 100644 (file)
index 0000000..19de6df
--- /dev/null
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Docx.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Document_OpenXml */
+require_once 'Zend/Search/Lucene/Document/OpenXml.php';
+
+if (class_exists('ZipArchive', false)) {
+
+/**
+ * Docx document.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Document_Docx extends Zend_Search_Lucene_Document_OpenXml {
+    /**
+     * Xml Schema - WordprocessingML
+     *
+     * @var string
+     */
+    const SCHEMA_WORDPROCESSINGML = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
+
+    /**
+     * Object constructor
+     *
+     * @param string  $fileName
+     * @param boolean $storeContent
+     */
+    private function __construct($fileName, $storeContent) {
+        // Document data holders
+        $documentBody = array();
+        $coreProperties = array();
+
+        // Open OpenXML package
+        $package = new ZipArchive();
+        $package->open($fileName);
+
+        // Read relations and search for officeDocument
+        $relations = simplexml_load_string($package->getFromName('_rels/.rels'));
+        foreach($relations->Relationship as $rel) {
+            if ($rel ["Type"] == Zend_Search_Lucene_Document_OpenXml::SCHEMA_OFFICEDOCUMENT) {
+                // Found office document! Read in contents...
+                $contents = simplexml_load_string($package->getFromName(
+                                                                $this->absoluteZipPath(dirname($rel['Target'])
+                                                              . '/'
+                                                              . basename($rel['Target']))
+                                                                       ));
+
+                $contents->registerXPathNamespace('w', Zend_Search_Lucene_Document_Docx::SCHEMA_WORDPROCESSINGML);
+                $paragraphs = $contents->xpath('//w:body/w:p');
+
+                foreach ($paragraphs as $paragraph) {
+                    $runs = $paragraph->xpath('.//w:r/*[name() = "w:t" or name() = "w:br"]');
+
+                    if ($runs === false) {
+                       // Paragraph doesn't contain any text or breaks
+                       continue;
+                    }
+
+                    foreach ($runs as $run) {
+                     if ($run->getName() == 'br') {
+                         // Break element
+                         $documentBody[] = ' ';
+                     } else {
+                       $documentBody[] = (string)$run;
+                     }
+                    }
+
+                    // Add space after each paragraph. So they are not bound together.
+                    $documentBody[] = ' ';
+                }
+
+                break;
+            }
+        }
+
+        // Read core properties
+        $coreProperties = $this->extractMetaData($package);
+
+        // Close file
+        $package->close();
+
+        // Store filename
+        $this->addField(Zend_Search_Lucene_Field::Text('filename', $fileName, 'UTF-8'));
+
+        // Store contents
+        if ($storeContent) {
+            $this->addField(Zend_Search_Lucene_Field::Text('body', implode('', $documentBody), 'UTF-8'));
+        } else {
+            $this->addField(Zend_Search_Lucene_Field::UnStored('body', implode('', $documentBody), 'UTF-8'));
+        }
+
+        // Store meta data properties
+        foreach ($coreProperties as $key => $value) {
+            $this->addField(Zend_Search_Lucene_Field::Text($key, $value, 'UTF-8'));
+        }
+
+        // Store title (if not present in meta data)
+        if (! isset($coreProperties['title'])) {
+            $this->addField(Zend_Search_Lucene_Field::Text('title', $fileName, 'UTF-8'));
+        }
+    }
+
+    /**
+     * Load Docx document from a file
+     *
+     * @param string  $fileName
+     * @param boolean $storeContent
+     * @return Zend_Search_Lucene_Document_Docx
+     * @throws Zend_Search_Lucene_Document_Exception
+     */
+    public static function loadDocxFile($fileName, $storeContent = false) {
+        if (!is_readable($fileName)) {
+               require_once 'Zend/Search/Lucene/Document/Exception.php';
+               throw new Zend_Search_Lucene_Document_Exception('Provided file \'' . $fileName . '\' is not readable.');
+        }
+
+       return new Zend_Search_Lucene_Document_Docx($fileName, $storeContent);
+    }
+}
+
+} // end if (class_exists('ZipArchive'))
diff --git a/inc/lib/Zend/Search/Lucene/Document/Exception.php b/inc/lib/Zend/Search/Lucene/Document/Exception.php
new file mode 100644 (file)
index 0000000..bb9a07d
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Exception.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/**
+ * Framework base exception
+ */
+require_once 'Zend/Search/Lucene/Exception.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Document_Exception extends Zend_Search_Lucene_Exception
+{}
+
diff --git a/inc/lib/Zend/Search/Lucene/Document/Html.php b/inc/lib/Zend/Search/Lucene/Document/Html.php
new file mode 100644 (file)
index 0000000..a325fc8
--- /dev/null
@@ -0,0 +1,457 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Html.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Document */
+require_once 'Zend/Search/Lucene/Document.php';
+
+
+/**
+ * HTML document.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Document_Html extends Zend_Search_Lucene_Document
+{
+    /**
+     * List of document links
+     *
+     * @var array
+     */
+    private $_links = array();
+
+    /**
+     * List of document header links
+     *
+     * @var array
+     */
+    private $_headerLinks = array();
+
+    /**
+     * Stored DOM representation
+     *
+     * @var DOMDocument
+     */
+    private $_doc;
+
+    /**
+     * Exclud nofollow links flag
+     *
+     * If true then links with rel='nofollow' attribute are not included into
+     * document links.
+     *
+     * @var boolean
+     */
+    private static $_excludeNoFollowLinks = false;
+
+    /**
+     * Object constructor
+     *
+     * @param string  $data         HTML string (may be HTML fragment, )
+     * @param boolean $isFile
+     * @param boolean $storeContent
+     * @param string  $defaultEncoding   HTML encoding, is used if it's not specified using Content-type HTTP-EQUIV meta tag.
+     */
+    private function __construct($data, $isFile, $storeContent, $defaultEncoding = '')
+    {
+        $this->_doc = new DOMDocument();
+        $this->_doc->substituteEntities = true;
+
+        if ($isFile) {
+            $htmlData = file_get_contents($data);
+        } else {
+            $htmlData = $data;
+        }
+        @$this->_doc->loadHTML($htmlData);
+
+        if ($this->_doc->encoding === null) {
+               // Document encoding is not recognized
+
+               /** @todo improve HTML vs HTML fragment recognition */
+               if (preg_match('/<html>/i', $htmlData, $matches, PREG_OFFSET_CAPTURE)) {
+                       // It's an HTML document
+                       // Add additional HEAD section and recognize document
+                       $htmlTagOffset = $matches[0][1] + strlen($matches[0][1]);
+
+                       @$this->_doc->loadHTML(iconv($defaultEncoding, 'UTF-8//IGNORE', substr($htmlData, 0, $htmlTagOffset))
+                                     . '<head><META HTTP-EQUIV="Content-type" CONTENT="text/html; charset=UTF-8"/></head>'
+                                     . iconv($defaultEncoding, 'UTF-8//IGNORE', substr($htmlData, $htmlTagOffset)));
+
+                // Remove additional HEAD section
+                $xpath = new DOMXPath($this->_doc);
+                $head  = $xpath->query('/html/head')->item(0);
+                               if (!$head || !$head->parentNode) {
+                                       throw new Exception("could not find html/head in this doc");
+                               }
+                       $head->parentNode->removeChild($head);
+               } else {
+                       // It's an HTML fragment
+                       @$this->_doc->loadHTML('<html><head><META HTTP-EQUIV="Content-type" CONTENT="text/html; charset=UTF-8"/></head><body>'
+                                            . iconv($defaultEncoding, 'UTF-8//IGNORE', $htmlData)
+                                            . '</body></html>');
+               }
+
+        }
+        /** @todo Add correction of wrong HTML encoding recognition processing
+         * The case is:
+         * Content-type HTTP-EQUIV meta tag is presented, but ISO-8859-5 encoding is actually used,
+         * even $this->_doc->encoding demonstrates another recognized encoding
+         */
+
+        $xpath = new DOMXPath($this->_doc);
+
+        $docTitle = '';
+        $titleNodes = $xpath->query('/html/head/title');
+        foreach ($titleNodes as $titleNode) {
+            // title should always have only one entry, but we process all nodeset entries
+            $docTitle .= $titleNode->nodeValue . ' ';
+        }
+        $this->addField(Zend_Search_Lucene_Field::Text('title', $docTitle, 'UTF-8'));
+
+        $metaNodes = $xpath->query('/html/head/meta[@name]');
+        foreach ($metaNodes as $metaNode) {
+            $this->addField(Zend_Search_Lucene_Field::Text($metaNode->getAttribute('name'),
+                                                           $metaNode->getAttribute('content'),
+                                                           'UTF-8'));
+        }
+
+        $docBody = '';
+        $bodyNodes = $xpath->query('/html/body');
+        foreach ($bodyNodes as $bodyNode) {
+            // body should always have only one entry, but we process all nodeset entries
+            $this->_retrieveNodeText($bodyNode, $docBody);
+        }
+        if ($storeContent) {
+            $this->addField(Zend_Search_Lucene_Field::Text('body', $docBody, 'UTF-8'));
+        } else {
+            $this->addField(Zend_Search_Lucene_Field::UnStored('body', $docBody, 'UTF-8'));
+        }
+
+        $linkNodes = $this->_doc->getElementsByTagName('a');
+        foreach ($linkNodes as $linkNode) {
+            if (($href = $linkNode->getAttribute('href')) != '' &&
+                (!self::$_excludeNoFollowLinks  ||  strtolower($linkNode->getAttribute('rel')) != 'nofollow' )
+               ) {
+                $this->_links[] = $href;
+            }
+        }
+        $this->_links = array_unique($this->_links);
+
+        $linkNodes = $xpath->query('/html/head/link');
+        foreach ($linkNodes as $linkNode) {
+            if (($href = $linkNode->getAttribute('href')) != '') {
+                $this->_headerLinks[] = $href;
+            }
+        }
+        $this->_headerLinks = array_unique($this->_headerLinks);
+    }
+
+    /**
+     * Set exclude nofollow links flag
+     *
+     * @param boolean $newValue
+     */
+    public static function setExcludeNoFollowLinks($newValue)
+    {
+        self::$_excludeNoFollowLinks = $newValue;
+    }
+
+    /**
+     * Get exclude nofollow links flag
+     *
+     * @return boolean
+     */
+    public static function getExcludeNoFollowLinks()
+    {
+        return self::$_excludeNoFollowLinks;
+    }
+
+    /**
+     * Get node text
+     *
+     * We should exclude scripts, which may be not included into comment tags, CDATA sections,
+     *
+     * @param DOMNode $node
+     * @param string &$text
+     */
+    private function _retrieveNodeText(DOMNode $node, &$text)
+    {
+        if ($node->nodeType == XML_TEXT_NODE) {
+            $text .= $node->nodeValue ;
+            $text .= ' ';
+        } else if ($node->nodeType == XML_ELEMENT_NODE  &&  $node->nodeName != 'script') {
+            foreach ($node->childNodes as $childNode) {
+                $this->_retrieveNodeText($childNode, $text);
+            }
+        }
+    }
+
+    /**
+     * Get document HREF links
+     *
+     * @return array
+     */
+    public function getLinks()
+    {
+        return $this->_links;
+    }
+
+    /**
+     * Get document header links
+     *
+     * @return array
+     */
+    public function getHeaderLinks()
+    {
+        return $this->_headerLinks;
+    }
+
+    /**
+     * Load HTML document from a string
+     *
+     * @param string  $data
+     * @param boolean $storeContent
+     * @param string  $defaultEncoding   HTML encoding, is used if it's not specified using Content-type HTTP-EQUIV meta tag.
+     * @return Zend_Search_Lucene_Document_Html
+     */
+    public static function loadHTML($data, $storeContent = false, $defaultEncoding = '')
+    {
+        return new Zend_Search_Lucene_Document_Html($data, false, $storeContent, $defaultEncoding);
+    }
+
+    /**
+     * Load HTML document from a file
+     *
+     * @param string  $file
+     * @param boolean $storeContent
+     * @param string  $defaultEncoding   HTML encoding, is used if it's not specified using Content-type HTTP-EQUIV meta tag.
+     * @return Zend_Search_Lucene_Document_Html
+     */
+    public static function loadHTMLFile($file, $storeContent = false, $defaultEncoding = '')
+    {
+        return new Zend_Search_Lucene_Document_Html($file, true, $storeContent, $defaultEncoding);
+    }
+
+
+    /**
+     * Highlight text in text node
+     *
+     * @param DOMText $node
+     * @param array   $wordsToHighlight
+     * @param callback $callback   Callback method, used to transform (highlighting) text.
+     * @param array    $params     Array of additionall callback parameters (first non-optional parameter is a text to transform)
+     * @throws Zend_Search_Lucene_Exception
+     */
+    protected function _highlightTextNode(DOMText $node, $wordsToHighlight, $callback, $params)
+    {
+        $analyzer = Zend_Search_Lucene_Analysis_Analyzer::getDefault();
+        $analyzer->setInput($node->nodeValue, 'UTF-8');
+
+        $matchedTokens = array();
+
+        while (($token = $analyzer->nextToken()) !== null) {
+            if (isset($wordsToHighlight[$token->getTermText()])) {
+                $matchedTokens[] = $token;
+            }
+        }
+
+        if (count($matchedTokens) == 0) {
+            return;
+        }
+
+        $matchedTokens = array_reverse($matchedTokens);
+
+        foreach ($matchedTokens as $token) {
+            // Cut text after matched token
+            $node->splitText($token->getEndOffset());
+
+            // Cut matched node
+            $matchedWordNode = $node->splitText($token->getStartOffset());
+
+            // Retrieve HTML string representation for highlihted word
+            $fullCallbackparamsList = $params;
+            array_unshift($fullCallbackparamsList, $matchedWordNode->nodeValue);
+            $highlightedWordNodeSetHtml = call_user_func_array($callback, $fullCallbackparamsList);
+
+            // Transform HTML string to a DOM representation and automatically transform retrieved string
+            // into valid XHTML (It's automatically done by loadHTML() method)
+            $highlightedWordNodeSetDomDocument = new DOMDocument('1.0', 'UTF-8');
+            $success = @$highlightedWordNodeSetDomDocument->
+                                loadHTML('<html><head><meta http-equiv="Content-type" content="text/html; charset=UTF-8"/></head><body>'
+                                       . $highlightedWordNodeSetHtml
+                                       . '</body></html>');
+            if (!$success) {
+               require_once 'Zend/Search/Lucene/Exception.php';
+               throw new Zend_Search_Lucene_Exception("Error occured while loading highlighted text fragment: '$highlightedNodeHtml'.");
+            }
+            $highlightedWordNodeSetXpath = new DOMXPath($highlightedWordNodeSetDomDocument);
+            $highlightedWordNodeSet      = $highlightedWordNodeSetXpath->query('/html/body')->item(0)->childNodes;
+
+            for ($count = 0; $count < $highlightedWordNodeSet->length; $count++) {
+               $nodeToImport = $highlightedWordNodeSet->item($count);
+               $node->parentNode->insertBefore($this->_doc->importNode($nodeToImport, true /* deep copy */),
+                                               $matchedWordNode);
+            }
+
+            $node->parentNode->removeChild($matchedWordNode);
+        }
+    }
+
+
+    /**
+     * highlight words in content of the specified node
+     *
+     * @param DOMNode $contextNode
+     * @param array $wordsToHighlight
+     * @param callback $callback   Callback method, used to transform (highlighting) text.
+     * @param array    $params     Array of additionall callback parameters (first non-optional parameter is a text to transform)
+     */
+    protected function _highlightNodeRecursive(DOMNode $contextNode, $wordsToHighlight, $callback, $params)
+    {
+        $textNodes = array();
+
+        if (!$contextNode->hasChildNodes()) {
+            return;
+        }
+
+        foreach ($contextNode->childNodes as $childNode) {
+            if ($childNode->nodeType == XML_TEXT_NODE) {
+                // process node later to leave childNodes structure untouched
+                $textNodes[] = $childNode;
+            } else {
+                // Process node if it's not a script node
+                if ($childNode->nodeName != 'script') {
+                    $this->_highlightNodeRecursive($childNode, $wordsToHighlight, $callback, $params);
+                }
+            }
+        }
+
+        foreach ($textNodes as $textNode) {
+            $this->_highlightTextNode($textNode, $wordsToHighlight, $callback, $params);
+        }
+    }
+
+    /**
+     * Standard callback method used to highlight words.
+     *
+     * @param  string  $stringToHighlight
+     * @return string
+     * @internal
+     */
+    public function applyColour($stringToHighlight, $colour)
+    {
+        return '<b style="color:black;background-color:' . $colour . '">' . $stringToHighlight . '</b>';
+    }
+
+    /**
+     * Highlight text with specified color
+     *
+     * @param string|array $words
+     * @param string $colour
+     * @return string
+     */
+    public function highlight($words, $colour = '#66ffff')
+    {
+       return $this->highlightExtended($words, array($this, 'applyColour'), array($colour));
+    }
+
+
+
+    /**
+     * Highlight text using specified View helper or callback function.
+     *
+     * @param string|array $words  Words to highlight. Words could be organized using the array or string.
+     * @param callback $callback   Callback method, used to transform (highlighting) text.
+     * @param array    $params     Array of additionall callback parameters passed through into it
+     *                             (first non-optional parameter is an HTML fragment for highlighting)
+     * @return string
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function highlightExtended($words, $callback, $params = array())
+    {
+        if (!is_array($words)) {
+            $words = array($words);
+        }
+
+        $wordsToHighlightList = array();
+        $analyzer = Zend_Search_Lucene_Analysis_Analyzer::getDefault();
+        foreach ($words as $wordString) {
+            $wordsToHighlightList[] = $analyzer->tokenize($wordString);
+        }
+        $wordsToHighlight = call_user_func_array('array_merge', $wordsToHighlightList);
+
+        if (count($wordsToHighlight) == 0) {
+            return $this->_doc->saveHTML();
+        }
+
+        $wordsToHighlightFlipped = array();
+        foreach ($wordsToHighlight as $id => $token) {
+            $wordsToHighlightFlipped[$token->getTermText()] = $id;
+        }
+
+        if (!is_callable($callback)) {
+               require_once 'Zend/Search/Lucene/Exception.php';
+               throw new Zend_Search_Lucene_Exception('$viewHelper parameter mast be a View Helper name, View Helper object or callback.');
+        }
+
+        $xpath = new DOMXPath($this->_doc);
+
+        $matchedNodes = $xpath->query("/html/body");
+        foreach ($matchedNodes as $matchedNode) {
+            $this->_highlightNodeRecursive($matchedNode, $wordsToHighlightFlipped, $callback, $params);
+        }
+    }
+
+
+    /**
+     * Get HTML
+     *
+     * @return string
+     */
+    public function getHTML()
+    {
+        return $this->_doc->saveHTML();
+    }
+
+    /**
+     * Get HTML body
+     *
+     * @return string
+     */
+    public function getHtmlBody()
+    {
+        $xpath = new DOMXPath($this->_doc);
+        $bodyNodes = $xpath->query('/html/body')->item(0)->childNodes;
+
+        $outputFragments = array();
+        for ($count = 0; $count < $bodyNodes->length; $count++) {
+               $outputFragments[] = $this->_doc->saveXML($bodyNodes->item($count));
+        }
+
+        return implode($outputFragments);
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Document/OpenXml.php b/inc/lib/Zend/Search/Lucene/Document/OpenXml.php
new file mode 100644 (file)
index 0000000..96492a2
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: OpenXml.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Document */
+require_once 'Zend/Search/Lucene/Document.php';
+
+if (class_exists('ZipArchive', false)) {
+
+/**
+ * OpenXML document.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Document_OpenXml extends Zend_Search_Lucene_Document
+{
+    /**
+     * Xml Schema - Relationships
+     *
+     * @var string
+     */
+    const SCHEMA_RELATIONSHIP = 'http://schemas.openxmlformats.org/package/2006/relationships';
+
+    /**
+     * Xml Schema - Office document
+     *
+     * @var string
+     */
+    const SCHEMA_OFFICEDOCUMENT = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument';
+
+    /**
+     * Xml Schema - Core properties
+     *
+     * @var string
+     */
+    const SCHEMA_COREPROPERTIES = 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties';
+
+    /**
+     * Xml Schema - Dublin Core
+     *
+     * @var string
+     */
+    const SCHEMA_DUBLINCORE = 'http://purl.org/dc/elements/1.1/';
+
+    /**
+     * Xml Schema - Dublin Core Terms
+     *
+     * @var string
+     */
+    const SCHEMA_DUBLINCORETERMS = 'http://purl.org/dc/terms/';
+
+    /**
+     * Extract metadata from document
+     *
+     * @param ZipArchive $package    ZipArchive OpenXML package
+     * @return array    Key-value pairs containing document meta data
+     */
+    protected function extractMetaData(ZipArchive $package)
+    {
+        // Data holders
+        $coreProperties = array();
+        
+        // Read relations and search for core properties
+        $relations = simplexml_load_string($package->getFromName("_rels/.rels"));
+        foreach ($relations->Relationship as $rel) {
+            if ($rel["Type"] == Zend_Search_Lucene_Document_OpenXml::SCHEMA_COREPROPERTIES) {
+                // Found core properties! Read in contents...
+                $contents = simplexml_load_string(
+                    $package->getFromName(dirname($rel["Target"]) . "/" . basename($rel["Target"]))
+                );
+
+                foreach ($contents->children(Zend_Search_Lucene_Document_OpenXml::SCHEMA_DUBLINCORE) as $child) {
+                    $coreProperties[$child->getName()] = (string)$child;
+                }
+                foreach ($contents->children(Zend_Search_Lucene_Document_OpenXml::SCHEMA_COREPROPERTIES) as $child) {
+                    $coreProperties[$child->getName()] = (string)$child;
+                }
+                foreach ($contents->children(Zend_Search_Lucene_Document_OpenXml::SCHEMA_DUBLINCORETERMS) as $child) {
+                    $coreProperties[$child->getName()] = (string)$child;
+                }
+            }
+        }
+        
+        return $coreProperties;
+    }
+    
+    /**
+     * Determine absolute zip path
+     *
+     * @param string $path
+     * @return string
+     */
+    protected function absoluteZipPath($path) {
+        $path = str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $path);
+        $parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), 'strlen');
+        $absolutes = array();
+        foreach ($parts as $part) {
+            if ('.' == $part) continue;
+            if ('..' == $part) {
+                array_pop($absolutes);
+            } else {
+                $absolutes[] = $part;
+            }
+        }
+        return implode('/', $absolutes);
+    }
+}
+
+} // end if (class_exists('ZipArchive'))
diff --git a/inc/lib/Zend/Search/Lucene/Document/Pptx.php b/inc/lib/Zend/Search/Lucene/Document/Pptx.php
new file mode 100644 (file)
index 0000000..6625170
--- /dev/null
@@ -0,0 +1,193 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Pptx.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Document_OpenXml */
+require_once 'Zend/Search/Lucene/Document/OpenXml.php';
+
+if (class_exists('ZipArchive', false)) {
+
+/**
+ * Pptx document.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Document_Pptx extends Zend_Search_Lucene_Document_OpenXml
+{
+    /**
+     * Xml Schema - PresentationML
+     *
+     * @var string
+     */
+    const SCHEMA_PRESENTATIONML = 'http://schemas.openxmlformats.org/presentationml/2006/main';
+
+    /**
+     * Xml Schema - DrawingML
+     *
+     * @var string
+     */
+    const SCHEMA_DRAWINGML = 'http://schemas.openxmlformats.org/drawingml/2006/main';
+
+    /**
+     * Xml Schema - Slide relation
+     *
+     * @var string
+     */
+    const SCHEMA_SLIDERELATION = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide';
+
+    /**
+     * Xml Schema - Slide notes relation
+     *
+     * @var string
+     */
+    const SCHEMA_SLIDENOTESRELATION = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide';
+
+    /**
+     * Object constructor
+     *
+     * @param string  $fileName
+     * @param boolean $storeContent
+     */
+    private function __construct($fileName, $storeContent)
+    {
+        // Document data holders
+        $slides = array();
+        $slideNotes = array();
+        $documentBody = array();
+        $coreProperties = array();
+
+        // Open OpenXML package
+        $package = new ZipArchive();
+        $package->open($fileName);
+
+        // Read relations and search for officeDocument
+        $relations = simplexml_load_string($package->getFromName("_rels/.rels"));
+        foreach ($relations->Relationship as $rel) {
+            if ($rel["Type"] == Zend_Search_Lucene_Document_OpenXml::SCHEMA_OFFICEDOCUMENT) {
+                // Found office document! Search for slides...
+                $slideRelations = simplexml_load_string($package->getFromName( $this->absoluteZipPath(dirname($rel["Target"]) . "/_rels/" . basename($rel["Target"]) . ".rels")) );
+                foreach ($slideRelations->Relationship as $slideRel) {
+                    if ($slideRel["Type"] == Zend_Search_Lucene_Document_Pptx::SCHEMA_SLIDERELATION) {
+                        // Found slide!
+                        $slides[ str_replace( 'rId', '', (string)$slideRel["Id"] ) ] = simplexml_load_string(
+                            $package->getFromName( $this->absoluteZipPath(dirname($rel["Target"]) . "/" . dirname($slideRel["Target"]) . "/" . basename($slideRel["Target"])) )
+                        );
+
+                        // Search for slide notes
+                        $slideNotesRelations = simplexml_load_string($package->getFromName( $this->absoluteZipPath(dirname($rel["Target"]) . "/" . dirname($slideRel["Target"]) . "/_rels/" . basename($slideRel["Target"]) . ".rels")) );
+                        foreach ($slideNotesRelations->Relationship as $slideNoteRel) {
+                            if ($slideNoteRel["Type"] == Zend_Search_Lucene_Document_Pptx::SCHEMA_SLIDENOTESRELATION) {
+                                // Found slide notes!
+                                $slideNotes[ str_replace( 'rId', '', (string)$slideRel["Id"] ) ] = simplexml_load_string(
+                                    $package->getFromName( $this->absoluteZipPath(dirname($rel["Target"]) . "/" . dirname($slideRel["Target"]) . "/" . dirname($slideNoteRel["Target"]) . "/" . basename($slideNoteRel["Target"])) )
+                                );
+
+                                break;
+                            }
+                        }
+                    }
+                }
+
+                break;
+            }
+        }
+
+        // Sort slides
+        ksort($slides);
+        ksort($slideNotes);
+
+        // Extract contents from slides
+        foreach ($slides as $slideKey => $slide) {
+            // Register namespaces
+            $slide->registerXPathNamespace("p", Zend_Search_Lucene_Document_Pptx::SCHEMA_PRESENTATIONML);
+            $slide->registerXPathNamespace("a", Zend_Search_Lucene_Document_Pptx::SCHEMA_DRAWINGML);
+
+            // Fetch all text
+            $textElements = $slide->xpath('//a:t');
+            foreach ($textElements as $textElement) {
+                $documentBody[] = (string)$textElement;
+            }
+
+            // Extract contents from slide notes
+            if (isset($slideNotes[$slideKey])) {
+                // Fetch slide note
+                $slideNote = $slideNotes[$slideKey];
+
+                // Register namespaces
+                $slideNote->registerXPathNamespace("p", Zend_Search_Lucene_Document_Pptx::SCHEMA_PRESENTATIONML);
+                $slideNote->registerXPathNamespace("a", Zend_Search_Lucene_Document_Pptx::SCHEMA_DRAWINGML);
+
+                // Fetch all text
+                $textElements = $slideNote->xpath('//a:t');
+                foreach ($textElements as $textElement) {
+                    $documentBody[] = (string)$textElement;
+                }
+            }
+        }
+
+        // Read core properties
+        $coreProperties = $this->extractMetaData($package);
+
+        // Close file
+        $package->close();
+
+        // Store filename
+        $this->addField(Zend_Search_Lucene_Field::Text('filename', $fileName, 'UTF-8'));
+
+            // Store contents
+        if ($storeContent) {
+            $this->addField(Zend_Search_Lucene_Field::Text('body', implode(' ', $documentBody), 'UTF-8'));
+        } else {
+            $this->addField(Zend_Search_Lucene_Field::UnStored('body', implode(' ', $documentBody), 'UTF-8'));
+        }
+
+        // Store meta data properties
+        foreach ($coreProperties as $key => $value)
+        {
+            $this->addField(Zend_Search_Lucene_Field::Text($key, $value, 'UTF-8'));
+        }
+
+        // Store title (if not present in meta data)
+        if (!isset($coreProperties['title']))
+        {
+            $this->addField(Zend_Search_Lucene_Field::Text('title', $fileName, 'UTF-8'));
+        }
+    }
+
+    /**
+     * Load Pptx document from a file
+     *
+     * @param string  $fileName
+     * @param boolean $storeContent
+     * @return Zend_Search_Lucene_Document_Pptx
+     */
+    public static function loadPptxFile($fileName, $storeContent = false)
+    {
+        return new Zend_Search_Lucene_Document_Pptx($fileName, $storeContent);
+    }
+}
+
+} // end if (class_exists('ZipArchive'))
diff --git a/inc/lib/Zend/Search/Lucene/Document/Xlsx.php b/inc/lib/Zend/Search/Lucene/Document/Xlsx.php
new file mode 100644 (file)
index 0000000..bcdda57
--- /dev/null
@@ -0,0 +1,256 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Xlsx.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Document_OpenXml */
+require_once 'Zend/Search/Lucene/Document/OpenXml.php';
+
+if (class_exists('ZipArchive', false)) {
+
+/**
+ * Xlsx document.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Document_Xlsx extends Zend_Search_Lucene_Document_OpenXml
+{
+    /**
+     * Xml Schema - SpreadsheetML
+     *
+     * @var string
+     */
+    const SCHEMA_SPREADSHEETML = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
+
+    /**
+     * Xml Schema - DrawingML
+     *
+     * @var string
+     */
+    const SCHEMA_DRAWINGML = 'http://schemas.openxmlformats.org/drawingml/2006/main';
+
+    /**
+     * Xml Schema - Shared Strings
+     *
+     * @var string
+     */
+    const SCHEMA_SHAREDSTRINGS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings';
+
+    /**
+     * Xml Schema - Worksheet relation
+     *
+     * @var string
+     */
+    const SCHEMA_WORKSHEETRELATION = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet';
+
+    /**
+     * Xml Schema - Slide notes relation
+     *
+     * @var string
+     */
+    const SCHEMA_SLIDENOTESRELATION = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide';
+
+    /**
+     * Object constructor
+     *
+     * @param string  $fileName
+     * @param boolean $storeContent
+     */
+    private function __construct($fileName, $storeContent)
+    {
+        // Document data holders
+        $sharedStrings = array();
+        $worksheets = array();
+        $documentBody = array();
+        $coreProperties = array();
+
+        // Open OpenXML package
+        $package = new ZipArchive();
+        $package->open($fileName);
+
+        // Read relations and search for officeDocument
+        $relations = simplexml_load_string($package->getFromName("_rels/.rels"));
+        foreach ($relations->Relationship as $rel) {
+            if ($rel["Type"] == Zend_Search_Lucene_Document_OpenXml::SCHEMA_OFFICEDOCUMENT) {
+                // Found office document! Read relations for workbook...
+                $workbookRelations = simplexml_load_string($package->getFromName( $this->absoluteZipPath(dirname($rel["Target"]) . "/_rels/" . basename($rel["Target"]) . ".rels")) );
+                $workbookRelations->registerXPathNamespace("rel", Zend_Search_Lucene_Document_OpenXml::SCHEMA_RELATIONSHIP);
+
+                // Read shared strings
+                $sharedStringsPath = $workbookRelations->xpath("rel:Relationship[@Type='" . Zend_Search_Lucene_Document_Xlsx::SCHEMA_SHAREDSTRINGS . "']");
+                $sharedStringsPath = (string)$sharedStringsPath[0]['Target'];
+                $xmlStrings = simplexml_load_string($package->getFromName( $this->absoluteZipPath(dirname($rel["Target"]) . "/" . $sharedStringsPath)) );
+                if (isset($xmlStrings) && isset($xmlStrings->si)) {
+                    foreach ($xmlStrings->si as $val) {
+                        if (isset($val->t)) {
+                            $sharedStrings[] = (string)$val->t;
+                        } elseif (isset($val->r)) {
+                            $sharedStrings[] = $this->_parseRichText($val);
+                        }
+                    }
+                }
+
+                // Loop relations for workbook and extract worksheets...
+                foreach ($workbookRelations->Relationship as $workbookRelation) {
+                    if ($workbookRelation["Type"] == Zend_Search_Lucene_Document_Xlsx::SCHEMA_WORKSHEETRELATION) {
+                        $worksheets[ str_replace( 'rId', '', (string)$workbookRelation["Id"]) ] = simplexml_load_string(
+                            $package->getFromName( $this->absoluteZipPath(dirname($rel["Target"]) . "/" . dirname($workbookRelation["Target"]) . "/" . basename($workbookRelation["Target"])) )
+                        );
+                    }
+                }
+
+                break;
+            }
+        }
+
+        // Sort worksheets
+        ksort($worksheets);
+
+        // Extract contents from worksheets
+        foreach ($worksheets as $sheetKey => $worksheet) {
+            foreach ($worksheet->sheetData->row as $row) {
+                foreach ($row->c as $c) {
+                    // Determine data type
+                    $dataType = (string)$c["t"];
+                    switch ($dataType) {
+                        case "s":
+                            // Value is a shared string
+                            if ((string)$c->v != '') {
+                                $value = $sharedStrings[intval($c->v)];
+                            } else {
+                                $value = '';
+                            }
+
+                            break;
+
+                        case "b":
+                            // Value is boolean
+                            $value = (string)$c->v;
+                            if ($value == '0') {
+                                $value = false;
+                            } else if ($value == '1') {
+                                $value = true;
+                            } else {
+                                $value = (bool)$c->v;
+                            }
+
+                            break;
+
+                        case "inlineStr":
+                            // Value is rich text inline
+                            $value = $this->_parseRichText($c->is);
+
+                            break;
+
+                        case "e":
+                            // Value is an error message
+                            if ((string)$c->v != '') {
+                                $value = (string)$c->v;
+                            } else {
+                                $value = '';
+                            }
+
+                            break;
+
+                        default:
+                            // Value is a string
+                            $value = (string)$c->v;
+
+                            // Check for numeric values
+                            if (is_numeric($value) && $dataType != 's') {
+                                if ($value == (int)$value) $value = (int)$value;
+                                elseif ($value == (float)$value) $value = (float)$value;
+                                elseif ($value == (double)$value) $value = (double)$value;
+                            }
+                    }
+
+                    $documentBody[] = $value;
+                }
+            }
+        }
+
+        // Read core properties
+        $coreProperties = $this->extractMetaData($package);
+
+        // Close file
+        $package->close();
+
+        // Store filename
+        $this->addField(Zend_Search_Lucene_Field::Text('filename', $fileName, 'UTF-8'));
+
+        // Store contents
+        if ($storeContent) {
+            $this->addField(Zend_Search_Lucene_Field::Text('body', implode(' ', $documentBody), 'UTF-8'));
+        } else {
+            $this->addField(Zend_Search_Lucene_Field::UnStored('body', implode(' ', $documentBody), 'UTF-8'));
+        }
+
+        // Store meta data properties
+        foreach ($coreProperties as $key => $value)
+        {
+            $this->addField(Zend_Search_Lucene_Field::Text($key, $value, 'UTF-8'));
+        }
+
+        // Store title (if not present in meta data)
+        if (!isset($coreProperties['title']))
+        {
+            $this->addField(Zend_Search_Lucene_Field::Text('title', $fileName, 'UTF-8'));
+        }
+    }
+
+    /**
+     * Parse rich text XML
+     *
+     * @param SimpleXMLElement $is
+     * @return string
+     */
+    private function _parseRichText($is = null) {
+        $value = array();
+
+        if (isset($is->t)) {
+            $value[] = (string)$is->t;
+        } else {
+            foreach ($is->r as $run) {
+                $value[] = (string)$run->t;
+            }
+        }
+
+        return implode('', $value);
+    }
+
+    /**
+     * Load Xlsx document from a file
+     *
+     * @param string  $fileName
+     * @param boolean $storeContent
+     * @return Zend_Search_Lucene_Document_Xlsx
+     */
+    public static function loadXlsxFile($fileName, $storeContent = false)
+    {
+        return new Zend_Search_Lucene_Document_Xlsx($fileName, $storeContent);
+    }
+}
+
+} // end if (class_exists('ZipArchive'))
diff --git a/inc/lib/Zend/Search/Lucene/Exception.php b/inc/lib/Zend/Search/Lucene/Exception.php
new file mode 100644 (file)
index 0000000..33ce457
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Exception.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * Framework base exception
+ */
+require_once 'Zend/Search/Exception.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Exception extends Zend_Search_Exception
+{}
+
diff --git a/inc/lib/Zend/Search/Lucene/FSM.php b/inc/lib/Zend/Search/Lucene/FSM.php
new file mode 100644 (file)
index 0000000..c97527e
--- /dev/null
@@ -0,0 +1,443 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: FSM.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_FSMAction */
+require_once 'Zend/Search/Lucene/FSMAction.php';
+
+/**
+ * Abstract Finite State Machine
+ *
+ * Take a look on Wikipedia state machine description: http://en.wikipedia.org/wiki/Finite_state_machine
+ *
+ * Any type of Transducers (Moore machine or Mealy machine) also may be implemented by using this abstract FSM.
+ * process() methods invokes a specified actions which may construct FSM output.
+ * Actions may be also used to signal, that we have reached Accept State
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_FSM
+{
+    /**
+     * Machine States alphabet
+     *
+     * @var array
+     */
+    private $_states = array();
+
+    /**
+     * Current state
+     *
+     * @var integer|string
+     */
+    private $_currentState = null;
+
+    /**
+     * Input alphabet
+     *
+     * @var array
+     */
+    private $_inputAphabet = array();
+
+    /**
+     * State transition table
+     *
+     * [sourceState][input] => targetState
+     *
+     * @var array
+     */
+    private $_rules = array();
+
+    /**
+     * List of entry actions
+     * Each action executes when entering the state
+     *
+     * [state] => action
+     *
+     * @var array
+     */
+    private $_entryActions =  array();
+
+    /**
+     * List of exit actions
+     * Each action executes when exiting the state
+     *
+     * [state] => action
+     *
+     * @var array
+     */
+    private $_exitActions =  array();
+
+    /**
+     * List of input actions
+     * Each action executes when entering the state
+     *
+     * [state][input] => action
+     *
+     * @var array
+     */
+    private $_inputActions =  array();
+
+    /**
+     * List of input actions
+     * Each action executes when entering the state
+     *
+     * [state1][state2] => action
+     *
+     * @var array
+     */
+    private $_transitionActions =  array();
+
+    /**
+     * Finite State machine constructor
+     *
+     * $states is an array of integers or strings with a list of possible machine states
+     * constructor treats fist list element as a sturt state (assignes it to $_current state).
+     * It may be reassigned by setState() call.
+     * States list may be empty and can be extended later by addState() or addStates() calls.
+     *
+     * $inputAphabet is the same as $states, but represents input alphabet
+     * it also may be extended later by addInputSymbols() or addInputSymbol() calls.
+     *
+     * $rules parameter describes FSM transitions and has a structure:
+     * array( array(sourseState, input, targetState[, inputAction]),
+     *        array(sourseState, input, targetState[, inputAction]),
+     *        array(sourseState, input, targetState[, inputAction]),
+     *        ...
+     *      )
+     * Rules also can be added later by addRules() and addRule() calls.
+     *
+     * FSM actions are very flexible and may be defined by addEntryAction(), addExitAction(),
+     * addInputAction() and addTransitionAction() calls.
+     *
+     * @param array $states
+     * @param array $inputAphabet
+     * @param array $rules
+     */
+    public function __construct($states = array(), $inputAphabet = array(), $rules = array())
+    {
+        $this->addStates($states);
+        $this->addInputSymbols($inputAphabet);
+        $this->addRules($rules);
+    }
+
+    /**
+     * Add states to the state machine
+     *
+     * @param array $states
+     */
+    public function addStates($states)
+    {
+        foreach ($states as $state) {
+            $this->addState($state);
+        }
+    }
+
+    /**
+     * Add state to the state machine
+     *
+     * @param integer|string $state
+     */
+    public function addState($state)
+    {
+        $this->_states[$state] = $state;
+
+        if ($this->_currentState === null) {
+            $this->_currentState = $state;
+        }
+    }
+
+    /**
+     * Set FSM state.
+     * No any action is invoked
+     *
+     * @param integer|string $state
+     * @throws Zend_Search_Exception
+     */
+    public function setState($state)
+    {
+        if (!isset($this->_states[$state])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('State \'' . $state . '\' is not on of the possible FSM states.');
+        }
+
+        $this->_currentState = $state;
+    }
+
+    /**
+     * Get FSM state.
+     *
+     * @return integer|string $state|null
+     */
+    public function getState()
+    {
+        return $this->_currentState;
+    }
+
+    /**
+     * Add symbols to the input alphabet
+     *
+     * @param array $inputAphabet
+     */
+    public function addInputSymbols($inputAphabet)
+    {
+        foreach ($inputAphabet as $inputSymbol) {
+            $this->addInputSymbol($inputSymbol);
+        }
+    }
+
+    /**
+     * Add symbol to the input alphabet
+     *
+     * @param integer|string $inputSymbol
+     */
+    public function addInputSymbol($inputSymbol)
+    {
+        $this->_inputAphabet[$inputSymbol] = $inputSymbol;
+    }
+
+
+    /**
+     * Add transition rules
+     *
+     * array structure:
+     * array( array(sourseState, input, targetState[, inputAction]),
+     *        array(sourseState, input, targetState[, inputAction]),
+     *        array(sourseState, input, targetState[, inputAction]),
+     *        ...
+     *      )
+     *
+     * @param array $rules
+     */
+    public function addRules($rules)
+    {
+        foreach ($rules as $rule) {
+            $this->addrule($rule[0], $rule[1], $rule[2], isset($rule[3])?$rule[3]:null);
+        }
+    }
+
+    /**
+     * Add symbol to the input alphabet
+     *
+     * @param integer|string $sourceState
+     * @param integer|string $input
+     * @param integer|string $targetState
+     * @param Zend_Search_Lucene_FSMAction|null $inputAction
+     * @throws Zend_Search_Exception
+     */
+    public function addRule($sourceState, $input, $targetState, $inputAction = null)
+    {
+        if (!isset($this->_states[$sourceState])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined source state (' . $sourceState . ').');
+        }
+        if (!isset($this->_states[$targetState])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined target state (' . $targetState . ').');
+        }
+        if (!isset($this->_inputAphabet[$input])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined input symbol (' . $input . ').');
+        }
+
+        if (!isset($this->_rules[$sourceState])) {
+            $this->_rules[$sourceState] = array();
+        }
+        if (isset($this->_rules[$sourceState][$input])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Rule for {state,input} pair (' . $sourceState . ', '. $input . ') is already defined.');
+        }
+
+        $this->_rules[$sourceState][$input] = $targetState;
+
+
+        if ($inputAction !== null) {
+            $this->addInputAction($sourceState, $input, $inputAction);
+        }
+    }
+
+
+    /**
+     * Add state entry action.
+     * Several entry actions are allowed.
+     * Action execution order is defined by addEntryAction() calls
+     *
+     * @param integer|string $state
+     * @param Zend_Search_Lucene_FSMAction $action
+     */
+    public function addEntryAction($state, Zend_Search_Lucene_FSMAction $action)
+    {
+        if (!isset($this->_states[$state])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined state (' . $state. ').');
+        }
+
+        if (!isset($this->_entryActions[$state])) {
+            $this->_entryActions[$state] = array();
+        }
+
+        $this->_entryActions[$state][] = $action;
+    }
+
+    /**
+     * Add state exit action.
+     * Several exit actions are allowed.
+     * Action execution order is defined by addEntryAction() calls
+     *
+     * @param integer|string $state
+     * @param Zend_Search_Lucene_FSMAction $action
+     */
+    public function addExitAction($state, Zend_Search_Lucene_FSMAction $action)
+    {
+        if (!isset($this->_states[$state])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined state (' . $state. ').');
+        }
+
+        if (!isset($this->_exitActions[$state])) {
+            $this->_exitActions[$state] = array();
+        }
+
+        $this->_exitActions[$state][] = $action;
+    }
+
+    /**
+     * Add input action (defined by {state, input} pair).
+     * Several input actions are allowed.
+     * Action execution order is defined by addInputAction() calls
+     *
+     * @param integer|string $state
+     * @param integer|string $input
+     * @param Zend_Search_Lucene_FSMAction $action
+     */
+    public function addInputAction($state, $inputSymbol, Zend_Search_Lucene_FSMAction $action)
+    {
+        if (!isset($this->_states[$state])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined state (' . $state. ').');
+        }
+        if (!isset($this->_inputAphabet[$inputSymbol])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined input symbol (' . $inputSymbol. ').');
+        }
+
+        if (!isset($this->_inputActions[$state])) {
+            $this->_inputActions[$state] = array();
+        }
+        if (!isset($this->_inputActions[$state][$inputSymbol])) {
+            $this->_inputActions[$state][$inputSymbol] = array();
+        }
+
+        $this->_inputActions[$state][$inputSymbol][] = $action;
+    }
+
+    /**
+     * Add transition action (defined by {state, input} pair).
+     * Several transition actions are allowed.
+     * Action execution order is defined by addTransitionAction() calls
+     *
+     * @param integer|string $sourceState
+     * @param integer|string $targetState
+     * @param Zend_Search_Lucene_FSMAction $action
+     */
+    public function addTransitionAction($sourceState, $targetState, Zend_Search_Lucene_FSMAction $action)
+    {
+        if (!isset($this->_states[$sourceState])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined source state (' . $sourceState. ').');
+        }
+        if (!isset($this->_states[$targetState])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('Undefined source state (' . $targetState. ').');
+        }
+
+        if (!isset($this->_transitionActions[$sourceState])) {
+            $this->_transitionActions[$sourceState] = array();
+        }
+        if (!isset($this->_transitionActions[$sourceState][$targetState])) {
+            $this->_transitionActions[$sourceState][$targetState] = array();
+        }
+
+        $this->_transitionActions[$sourceState][$targetState][] = $action;
+    }
+
+
+    /**
+     * Process an input
+     *
+     * @param mixed $input
+     * @throws Zend_Search_Exception
+     */
+    public function process($input)
+    {
+        if (!isset($this->_rules[$this->_currentState])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('There is no any rule for current state (' . $this->_currentState . ').');
+        }
+        if (!isset($this->_rules[$this->_currentState][$input])) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('There is no any rule for {current state, input} pair (' . $this->_currentState . ', ' . $input . ').');
+        }
+
+        $sourceState = $this->_currentState;
+        $targetState = $this->_rules[$this->_currentState][$input];
+
+        if ($sourceState != $targetState  &&  isset($this->_exitActions[$sourceState])) {
+            foreach ($this->_exitActions[$sourceState] as $action) {
+                $action->doAction();
+            }
+        }
+        if (isset($this->_inputActions[$sourceState]) &&
+            isset($this->_inputActions[$sourceState][$input])) {
+            foreach ($this->_inputActions[$sourceState][$input] as $action) {
+                $action->doAction();
+            }
+        }
+
+
+        $this->_currentState = $targetState;
+
+        if (isset($this->_transitionActions[$sourceState]) &&
+            isset($this->_transitionActions[$sourceState][$targetState])) {
+            foreach ($this->_transitionActions[$sourceState][$targetState] as $action) {
+                $action->doAction();
+            }
+        }
+        if ($sourceState != $targetState  &&  isset($this->_entryActions[$targetState])) {
+            foreach ($this->_entryActions[$targetState] as $action) {
+                $action->doAction();
+            }
+        }
+    }
+
+    public function reset()
+    {
+        if (count($this->_states) == 0) {
+            require_once 'Zend/Search/Exception.php';
+            throw new Zend_Search_Exception('There is no any state defined for FSM.');
+        }
+
+        $this->_currentState = $this->_states[0];
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/FSMAction.php b/inc/lib/Zend/Search/Lucene/FSMAction.php
new file mode 100644 (file)
index 0000000..bb6007e
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: FSMAction.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/**
+ * Abstract Finite State Machine
+ *
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_FSMAction
+{
+    /**
+     * Object reference
+     *
+     * @var object
+     */
+    private $_object;
+
+    /**
+     * Method name
+     *
+     * @var string
+     */
+    private $_method;
+
+    /**
+     * Object constructor
+     *
+     * @param object $object
+     * @param string $method
+     */
+    public function __construct($object, $method)
+    {
+        $this->_object = $object;
+        $this->_method = $method;
+    }
+
+    public function doAction()
+    {
+        $methodName = $this->_method;
+        $this->_object->$methodName();
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Field.php b/inc/lib/Zend/Search/Lucene/Field.php
new file mode 100644 (file)
index 0000000..c8465d3
--- /dev/null
@@ -0,0 +1,226 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Field.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * A field is a section of a Document.  Each field has two parts,
+ * a name and a value. Values may be free text or they may be atomic
+ * keywords, which are not further processed. Such keywords may
+ * be used to represent dates, urls, etc.  Fields are optionally
+ * stored in the index, so that they may be returned with hits
+ * on the document.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Document
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Field
+{
+    /**
+     * Field name
+     *
+     * @var string
+     */
+    public $name;
+
+    /**
+     * Field value
+     * 
+     * @var boolean
+     */
+    public $value;
+    
+    /**
+     * Field is to be stored in the index for return with search hits.
+     * 
+     * @var boolean
+     */
+    public $isStored    = false;
+    
+    /**
+     * Field is to be indexed, so that it may be searched on.
+     * 
+     * @var boolean
+     */
+    public $isIndexed   = true;
+
+    /**
+     * Field should be tokenized as text prior to indexing.
+     * 
+     * @var boolean
+     */
+    public $isTokenized = true;
+    /**
+     * Field is stored as binary.
+     * 
+     * @var boolean
+     */
+    public $isBinary    = false;
+
+    /**
+     * Field are stored as a term vector
+     * 
+     * @var boolean
+     */
+    public $storeTermVector = false;
+
+    /**
+     * Field boost factor
+     * It's not stored directly in the index, but affects on normalization factor
+     *
+     * @var float
+     */
+    public $boost = 1.0;
+
+    /**
+     * Field value encoding.
+     *
+     * @var string
+     */
+    public $encoding;
+
+    /**
+     * Object constructor
+     *
+     * @param string $name
+     * @param string $value
+     * @param string $encoding
+     * @param boolean $isStored
+     * @param boolean $isIndexed
+     * @param boolean $isTokenized
+     * @param boolean $isBinary
+     */
+    public function __construct($name, $value, $encoding, $isStored, $isIndexed, $isTokenized, $isBinary = false)
+    {
+        $this->name  = $name;
+        $this->value = $value;
+
+        if (!$isBinary) {
+            $this->encoding    = $encoding;
+            $this->isTokenized = $isTokenized;
+        } else {
+            $this->encoding    = '';
+            $this->isTokenized = false;
+        }
+
+        $this->isStored  = $isStored;
+        $this->isIndexed = $isIndexed;
+        $this->isBinary  = $isBinary;
+
+        $this->storeTermVector = false;
+        $this->boost           = 1.0;
+    }
+
+
+    /**
+     * Constructs a String-valued Field that is not tokenized, but is indexed
+     * and stored.  Useful for non-text fields, e.g. date or url.
+     *
+     * @param string $name
+     * @param string $value
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Field
+     */
+    public static function keyword($name, $value, $encoding = '')
+    {
+        return new self($name, $value, $encoding, true, true, false);
+    }
+
+
+    /**
+     * Constructs a String-valued Field that is not tokenized nor indexed,
+     * but is stored in the index, for return with hits.
+     *
+     * @param string $name
+     * @param string $value
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Field
+     */
+    public static function unIndexed($name, $value, $encoding = '')
+    {
+        return new self($name, $value, $encoding, true, false, false);
+    }
+
+
+    /**
+     * Constructs a Binary String valued Field that is not tokenized nor indexed,
+     * but is stored in the index, for return with hits.
+     *
+     * @param string $name
+     * @param string $value
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Field
+     */
+    public static function binary($name, $value)
+    {
+        return new self($name, $value, '', true, false, false, true);
+    }
+
+    /**
+     * Constructs a String-valued Field that is tokenized and indexed,
+     * and is stored in the index, for return with hits.  Useful for short text
+     * fields, like "title" or "subject". Term vector will not be stored for this field.
+     *
+     * @param string $name
+     * @param string $value
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Field
+     */
+    public static function text($name, $value, $encoding = '')
+    {
+        return new self($name, $value, $encoding, true, true, true);
+    }
+
+
+    /**
+     * Constructs a String-valued Field that is tokenized and indexed,
+     * but that is not stored in the index.
+     *
+     * @param string $name
+     * @param string $value
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Field
+     */
+    public static function unStored($name, $value, $encoding = '')
+    {
+        return new self($name, $value, $encoding, false, true, true);
+    }
+
+    /**
+     * Get field value in UTF-8 encoding
+     *
+     * @return string
+     */
+    public function getUtf8Value()
+    {
+        if (strcasecmp($this->encoding, 'utf8' ) == 0  ||
+            strcasecmp($this->encoding, 'utf-8') == 0 ) {
+                return $this->value;
+        } else {
+            
+            return (PHP_OS != 'AIX') ? iconv($this->encoding, 'UTF-8', $this->value) : iconv('ISO8859-1', 'UTF-8', $this->value);
+        }
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/DictionaryLoader.php b/inc/lib/Zend/Search/Lucene/Index/DictionaryLoader.php
new file mode 100644 (file)
index 0000000..bc1a41c
--- /dev/null
@@ -0,0 +1,268 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: DictionaryLoader.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/**
+ * Dictionary loader
+ *
+ * It's a dummy class which is created to encapsulate non-good structured code.
+ * Manual "method inlining" is performed to increase dictionary index loading operation
+ * which is major bottelneck for search performance.
+ *
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_DictionaryLoader
+{
+    /**
+     * Dictionary index loader.
+     *
+     * It takes a string which is actually <segment_name>.tii index file data and
+     * returns two arrays - term and tremInfo lists.
+     *
+     * See Zend_Search_Lucene_Index_SegmintInfo class for details
+     *
+     * @param string $data
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public static function load($data)
+    {
+        $termDictionary = array();
+        $termInfos      = array();
+        $pos = 0;
+
+        // $tiVersion = $tiiFile->readInt();
+        $tiVersion = ord($data[0]) << 24 | ord($data[1]) << 16 | ord($data[2]) << 8  | ord($data[3]);
+        $pos += 4;
+        if ($tiVersion != (int)0xFFFFFFFE /* pre-2.1 format */ &&
+            $tiVersion != (int)0xFFFFFFFD /* 2.1+ format    */) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Wrong TermInfoIndexFile file format');
+        }
+
+        // $indexTermCount = $tiiFile->readLong();
+        if (PHP_INT_SIZE > 4) {
+            $indexTermCount = ord($data[$pos]) << 56  |
+                              ord($data[$pos+1]) << 48  |
+                              ord($data[$pos+2]) << 40  |
+                              ord($data[$pos+3]) << 32  |
+                              ord($data[$pos+4]) << 24  |
+                              ord($data[$pos+5]) << 16  |
+                              ord($data[$pos+6]) << 8   |
+                              ord($data[$pos+7]);
+        } else {
+            if ((ord($data[$pos])            != 0) ||
+                (ord($data[$pos+1])          != 0) ||
+                (ord($data[$pos+2])          != 0) ||
+                (ord($data[$pos+3])          != 0) ||
+                ((ord($data[$pos+4]) & 0x80) != 0)) {
+                    require_once 'Zend/Search/Lucene/Exception.php';
+                    throw new Zend_Search_Lucene_Exception('Largest supported segment size (for 32-bit mode) is 2Gb');
+                 }
+
+            $indexTermCount = ord($data[$pos+4]) << 24  |
+                              ord($data[$pos+5]) << 16  |
+                              ord($data[$pos+6]) << 8   |
+                              ord($data[$pos+7]);
+        }
+        $pos += 8;
+
+        //                  $tiiFile->readInt();  // IndexInterval
+        $pos += 4;
+
+        // $skipInterval   = $tiiFile->readInt();
+        $skipInterval = ord($data[$pos]) << 24 | ord($data[$pos+1]) << 16 | ord($data[$pos+2]) << 8  | ord($data[$pos+3]);
+        $pos += 4;
+        if ($indexTermCount < 1) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Wrong number of terms in a term dictionary index');
+        }
+
+        if ($tiVersion == (int)0xFFFFFFFD /* 2.1+ format */) {
+            /* Skip MaxSkipLevels value */
+            $pos += 4;
+        }
+
+        $prevTerm     = '';
+        $freqPointer  =  0;
+        $proxPointer  =  0;
+        $indexPointer =  0;
+        for ($count = 0; $count < $indexTermCount; $count++) {
+            //$termPrefixLength = $tiiFile->readVInt();
+            $nbyte = ord($data[$pos++]);
+            $termPrefixLength = $nbyte & 0x7F;
+            for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                $nbyte = ord($data[$pos++]);
+                $termPrefixLength |= ($nbyte & 0x7F) << $shift;
+            }
+
+            // $termSuffix       = $tiiFile->readString();
+            $nbyte = ord($data[$pos++]);
+            $len = $nbyte & 0x7F;
+            for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                $nbyte = ord($data[$pos++]);
+                $len |= ($nbyte & 0x7F) << $shift;
+            }
+            if ($len == 0) {
+                $termSuffix = '';
+            } else {
+                $termSuffix = substr($data, $pos, $len);
+                $pos += $len;
+                for ($count1 = 0; $count1 < $len; $count1++ ) {
+                    if (( ord($termSuffix[$count1]) & 0xC0 ) == 0xC0) {
+                        $addBytes = 1;
+                        if (ord($termSuffix[$count1]) & 0x20 ) {
+                            $addBytes++;
+
+                            // Never used for Java Lucene created index.
+                            // Java2 doesn't encode strings in four bytes
+                            if (ord($termSuffix[$count1]) & 0x10 ) {
+                                $addBytes++;
+                            }
+                        }
+                        $termSuffix .= substr($data, $pos, $addBytes);
+                        $pos += $addBytes;
+                        $len += $addBytes;
+
+                        // Check for null character. Java2 encodes null character
+                        // in two bytes.
+                        if (ord($termSuffix[$count1]) == 0xC0 &&
+                            ord($termSuffix[$count1+1]) == 0x80   ) {
+                            $termSuffix[$count1] = 0;
+                            $termSuffix = substr($termSuffix,0,$count1+1)
+                                        . substr($termSuffix,$count1+2);
+                        }
+                        $count1 += $addBytes;
+                    }
+                }
+            }
+
+            // $termValue        = Zend_Search_Lucene_Index_Term::getPrefix($prevTerm, $termPrefixLength) . $termSuffix;
+            $pb = 0; $pc = 0;
+            while ($pb < strlen($prevTerm)  &&  $pc < $termPrefixLength) {
+                $charBytes = 1;
+                if ((ord($prevTerm[$pb]) & 0xC0) == 0xC0) {
+                    $charBytes++;
+                    if (ord($prevTerm[$pb]) & 0x20 ) {
+                        $charBytes++;
+                        if (ord($prevTerm[$pb]) & 0x10 ) {
+                            $charBytes++;
+                        }
+                    }
+                }
+
+                if ($pb + $charBytes > strlen($data)) {
+                    // wrong character
+                    break;
+                }
+
+                $pc++;
+                $pb += $charBytes;
+            }
+            $termValue = substr($prevTerm, 0, $pb) . $termSuffix;
+
+            // $termFieldNum     = $tiiFile->readVInt();
+            $nbyte = ord($data[$pos++]);
+            $termFieldNum = $nbyte & 0x7F;
+            for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                $nbyte = ord($data[$pos++]);
+                $termFieldNum |= ($nbyte & 0x7F) << $shift;
+            }
+
+            // $docFreq          = $tiiFile->readVInt();
+            $nbyte = ord($data[$pos++]);
+            $docFreq = $nbyte & 0x7F;
+            for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                $nbyte = ord($data[$pos++]);
+                $docFreq |= ($nbyte & 0x7F) << $shift;
+            }
+
+            // $freqPointer     += $tiiFile->readVInt();
+            $nbyte = ord($data[$pos++]);
+            $vint = $nbyte & 0x7F;
+            for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                $nbyte = ord($data[$pos++]);
+                $vint |= ($nbyte & 0x7F) << $shift;
+            }
+            $freqPointer += $vint;
+
+            // $proxPointer     += $tiiFile->readVInt();
+            $nbyte = ord($data[$pos++]);
+            $vint = $nbyte & 0x7F;
+            for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                $nbyte = ord($data[$pos++]);
+                $vint |= ($nbyte & 0x7F) << $shift;
+            }
+            $proxPointer += $vint;
+
+            if( $docFreq >= $skipInterval ) {
+                // $skipDelta = $tiiFile->readVInt();
+                $nbyte = ord($data[$pos++]);
+                $vint = $nbyte & 0x7F;
+                for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                    $nbyte = ord($data[$pos++]);
+                    $vint |= ($nbyte & 0x7F) << $shift;
+                }
+                $skipDelta = $vint;
+            } else {
+                $skipDelta = 0;
+            }
+
+            // $indexPointer += $tiiFile->readVInt();
+            $nbyte = ord($data[$pos++]);
+            $vint = $nbyte & 0x7F;
+            for ($shift=7; ($nbyte & 0x80) != 0; $shift += 7) {
+                $nbyte = ord($data[$pos++]);
+                $vint |= ($nbyte & 0x7F) << $shift;
+            }
+            $indexPointer += $vint;
+
+
+            // $this->_termDictionary[] =  new Zend_Search_Lucene_Index_Term($termValue, $termFieldNum);
+            $termDictionary[] = array($termFieldNum, $termValue);
+
+            $termInfos[] =
+                 // new Zend_Search_Lucene_Index_TermInfo($docFreq, $freqPointer, $proxPointer, $skipDelta, $indexPointer);
+                 array($docFreq, $freqPointer, $proxPointer, $skipDelta, $indexPointer);
+
+            $prevTerm = $termValue;
+        }
+
+        // Check special index entry mark
+        if ($termDictionary[0][0] != (int)0xFFFFFFFF) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Wrong TermInfoIndexFile file format');
+        }
+
+        if (PHP_INT_SIZE > 4) {
+            // Treat 64-bit 0xFFFFFFFF as -1
+            $termDictionary[0][0] = -1;
+        }
+
+        return array($termDictionary, $termInfos);
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/DocsFilter.php b/inc/lib/Zend/Search/Lucene/Index/DocsFilter.php
new file mode 100644 (file)
index 0000000..b531bb8
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: DocsFilter.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/**
+ * A Zend_Search_Lucene_Index_DocsFilter is used to filter documents while searching.
+ *
+ * It may or _may_not_ be used for actual filtering, so it's just a hint that upper query limits
+ * search result by specified list.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_DocsFilter
+{
+    /**
+     * Set of segment filters:
+     *  array( <segmentName> => array(<docId> => <undefined_value>,
+     *                                <docId> => <undefined_value>,
+     *                                <docId> => <undefined_value>,
+     *                                ...                          ),
+     *         <segmentName> => array(<docId> => <undefined_value>,
+     *                                <docId> => <undefined_value>,
+     *                                <docId> => <undefined_value>,
+     *                                ...                          ),
+     *         <segmentName> => array(<docId> => <undefined_value>,
+     *                                <docId> => <undefined_value>,
+     *                                <docId> => <undefined_value>,
+     *                                ...                          ),
+     *         ...
+     *       )
+     *
+     * @var array
+     */
+    public $segmentFilters = array();
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/FieldInfo.php b/inc/lib/Zend/Search/Lucene/Index/FieldInfo.php
new file mode 100644 (file)
index 0000000..a3b9d2e
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: FieldInfo.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_FieldInfo
+{
+    public $name;
+    public $isIndexed;
+    public $number;
+    public $storeTermVector;
+    public $normsOmitted;
+    public $payloadsStored;
+
+    public function __construct($name, $isIndexed, $number, $storeTermVector, $normsOmitted = false, $payloadsStored = false)
+    {
+        $this->name            = $name;
+        $this->isIndexed       = $isIndexed;
+        $this->number          = $number;
+        $this->storeTermVector = $storeTermVector;
+        $this->normsOmitted    = $normsOmitted;
+        $this->payloadsStored  = $payloadsStored;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/SegmentInfo.php b/inc/lib/Zend/Search/Lucene/Index/SegmentInfo.php
new file mode 100644 (file)
index 0000000..c4c05b2
--- /dev/null
@@ -0,0 +1,2117 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: SegmentInfo.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Index_DictionaryLoader */
+require_once 'Zend/Search/Lucene/Index/DictionaryLoader.php';
+
+/** Zend_Search_Lucene_Index_DocsFilter */
+require_once 'Zend/Search/Lucene/Index/DocsFilter.php';
+
+/** Zend_Search_Lucene_Index_TermsStream_Interface */
+require_once 'Zend/Search/Lucene/Index/TermsStream/Interface.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_SegmentInfo implements Zend_Search_Lucene_Index_TermsStream_Interface
+{
+    /**
+     * "Full scan vs fetch" boundary.
+     *
+     * If filter selectivity is less than this value, then full scan is performed
+     * (since term entries fetching has some additional overhead).
+     */
+    const FULL_SCAN_VS_FETCH_BOUNDARY = 5;
+
+    /**
+     * Number of docs in a segment
+     *
+     * @var integer
+     */
+    private $_docCount;
+
+    /**
+     * Segment name
+     *
+     * @var string
+     */
+    private $_name;
+
+    /**
+     * Term Dictionary Index
+     *
+     * Array of arrays (Zend_Search_Lucene_Index_Term objects are represented as arrays because
+     * of performance considerations)
+     * [0] -> $termValue
+     * [1] -> $termFieldNum
+     *
+     * Corresponding Zend_Search_Lucene_Index_TermInfo object stored in the $_termDictionaryInfos
+     *
+     * @var array
+     */
+    private $_termDictionary;
+
+    /**
+     * Term Dictionary Index TermInfos
+     *
+     * Array of arrays (Zend_Search_Lucene_Index_TermInfo objects are represented as arrays because
+     * of performance considerations)
+     * [0] -> $docFreq
+     * [1] -> $freqPointer
+     * [2] -> $proxPointer
+     * [3] -> $skipOffset
+     * [4] -> $indexPointer
+     *
+     * @var array
+     */
+    private $_termDictionaryInfos;
+
+    /**
+     * Segment fields. Array of Zend_Search_Lucene_Index_FieldInfo objects for this segment
+     *
+     * @var array
+     */
+    private $_fields;
+
+    /**
+     * Field positions in a dictionary.
+     * (Term dictionary contains filelds ordered by names)
+     *
+     * @var array
+     */
+    private $_fieldsDicPositions;
+
+
+    /**
+     * Associative array where the key is the file name and the value is data offset
+     * in a compound segment file (.csf).
+     *
+     * @var array
+     */
+    private $_segFiles;
+
+    /**
+     * Associative array where the key is the file name and the value is file size (.csf).
+     *
+     * @var array
+     */
+    private $_segFileSizes;
+
+    /**
+     * Delete file generation number
+     *
+     * -2 means autodetect latest delete generation
+     * -1 means 'there is no delete file'
+     *  0 means pre-2.1 format delete file
+     *  X specifies used delete file
+     *
+     * @var integer
+     */
+    private $_delGen;
+
+    /**
+     * Segment has single norms file
+     *
+     * If true then one .nrm file is used for all fields
+     * Otherwise .fN files are used
+     *
+     * @var boolean
+     */
+    private $_hasSingleNormFile;
+
+    /**
+     * Use compound segment file (*.cfs) to collect all other segment files
+     * (excluding .del files)
+     *
+     * @var boolean
+     */
+    private $_isCompound;
+
+
+    /**
+     * File system adapter.
+     *
+     * @var Zend_Search_Lucene_Storage_Directory_Filesystem
+     */
+    private $_directory;
+
+    /**
+     * Normalization factors.
+     * An array fieldName => normVector
+     * normVector is a binary string.
+     * Each byte corresponds to an indexed document in a segment and
+     * encodes normalization factor (float value, encoded by
+     * Zend_Search_Lucene_Search_Similarity::encodeNorm())
+     *
+     * @var array
+     */
+    private $_norms = array();
+
+    /**
+     * List of deleted documents.
+     * bitset if bitset extension is loaded or array otherwise.
+     *
+     * @var mixed
+     */
+    private $_deleted = null;
+
+    /**
+     * $this->_deleted update flag
+     *
+     * @var boolean
+     */
+    private $_deletedDirty = false;
+
+    /**
+     * True if segment uses shared doc store
+     *
+     * @var boolean
+     */
+    private $_usesSharedDocStore;
+
+    /*
+     * Shared doc store options.
+     * It's an assotiative array with the following items:
+     * - 'offset'     => $docStoreOffset           The starting document in the shared doc store files where this segment's documents begin
+     * - 'segment'    => $docStoreSegment          The name of the segment that has the shared doc store files.
+     * - 'isCompound' => $docStoreIsCompoundFile   True, if compound file format is used for the shared doc store files (.cfx file).
+     */
+    private $_sharedDocStoreOptions;
+
+
+    /**
+     * Zend_Search_Lucene_Index_SegmentInfo constructor
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @param string     $name
+     * @param integer    $docCount
+     * @param integer    $delGen
+     * @param array|null $docStoreOptions
+     * @param boolean    $hasSingleNormFile
+     * @param boolean    $isCompound
+     */
+    public function __construct(Zend_Search_Lucene_Storage_Directory $directory, $name, $docCount, $delGen = 0, $docStoreOptions = null, $hasSingleNormFile = false, $isCompound = null)
+    {
+        $this->_directory = $directory;
+        $this->_name      = $name;
+        $this->_docCount  = $docCount;
+
+        if ($docStoreOptions !== null) {
+            $this->_usesSharedDocStore    = true;
+            $this->_sharedDocStoreOptions = $docStoreOptions;
+
+            if ($docStoreOptions['isCompound']) {
+                $cfxFile       = $this->_directory->getFileObject($docStoreOptions['segment'] . '.cfx');
+                $cfxFilesCount = $cfxFile->readVInt();
+
+                $cfxFiles     = array();
+                $cfxFileSizes = array();
+
+                for ($count = 0; $count < $cfxFilesCount; $count++) {
+                    $dataOffset = $cfxFile->readLong();
+                    if ($count != 0) {
+                        $cfxFileSizes[$fileName] = $dataOffset - end($cfxFiles);
+                    }
+                    $fileName            = $cfxFile->readString();
+                    $cfxFiles[$fileName] = $dataOffset;
+                }
+                if ($count != 0) {
+                    $cfxFileSizes[$fileName] = $this->_directory->fileLength($docStoreOptions['segment'] . '.cfx') - $dataOffset;
+                }
+
+                $this->_sharedDocStoreOptions['files']     = $cfxFiles;
+                $this->_sharedDocStoreOptions['fileSizes'] = $cfxFileSizes;
+            }
+        }
+
+        $this->_hasSingleNormFile = $hasSingleNormFile;
+        $this->_delGen            = $delGen;
+        $this->_termDictionary    = null;
+
+
+        if ($isCompound !== null) {
+            $this->_isCompound    = $isCompound;
+        } else {
+            // It's a pre-2.1 segment or isCompound is set to 'unknown'
+            // Detect if segment uses compound file
+            require_once 'Zend/Search/Lucene/Exception.php';
+            try {
+                // Try to open compound file
+                $this->_directory->getFileObject($name . '.cfs');
+
+                // Compound file is found
+                $this->_isCompound = true;
+            } catch (Zend_Search_Lucene_Exception $e) {
+                if (strpos($e->getMessage(), 'is not readable') !== false) {
+                    // Compound file is not found or is not readable
+                    $this->_isCompound = false;
+                } else {
+                    throw $e;
+                }
+            }
+        }
+
+        $this->_segFiles = array();
+        if ($this->_isCompound) {
+            $cfsFile = $this->_directory->getFileObject($name . '.cfs');
+            $segFilesCount = $cfsFile->readVInt();
+
+            for ($count = 0; $count < $segFilesCount; $count++) {
+                $dataOffset = $cfsFile->readLong();
+                if ($count != 0) {
+                    $this->_segFileSizes[$fileName] = $dataOffset - end($this->_segFiles);
+                }
+                $fileName = $cfsFile->readString();
+                $this->_segFiles[$fileName] = $dataOffset;
+            }
+            if ($count != 0) {
+                $this->_segFileSizes[$fileName] = $this->_directory->fileLength($name . '.cfs') - $dataOffset;
+            }
+        }
+
+        $fnmFile = $this->openCompoundFile('.fnm');
+        $fieldsCount = $fnmFile->readVInt();
+        $fieldNames = array();
+        $fieldNums  = array();
+        $this->_fields = array();
+        for ($count=0; $count < $fieldsCount; $count++) {
+            $fieldName = $fnmFile->readString();
+            $fieldBits = $fnmFile->readByte();
+            $this->_fields[$count] = new Zend_Search_Lucene_Index_FieldInfo($fieldName,
+                                                                            $fieldBits & 0x01 /* field is indexed */,
+                                                                            $count,
+                                                                            $fieldBits & 0x02 /* termvectors are stored */,
+                                                                            $fieldBits & 0x10 /* norms are omitted */,
+                                                                            $fieldBits & 0x20 /* payloads are stored */);
+            if ($fieldBits & 0x10) {
+                // norms are omitted for the indexed field
+                $this->_norms[$count] = str_repeat(chr(Zend_Search_Lucene_Search_Similarity::encodeNorm(1.0)), $docCount);
+            }
+
+            $fieldNums[$count]  = $count;
+            $fieldNames[$count] = $fieldName;
+        }
+        array_multisort($fieldNames, SORT_ASC, SORT_REGULAR, $fieldNums);
+        $this->_fieldsDicPositions = array_flip($fieldNums);
+
+        if ($this->_delGen == -2) {
+               // SegmentInfo constructor is invoked from index writer
+               // Autodetect current delete file generation number
+            $this->_delGen = $this->_detectLatestDelGen();
+        }
+
+        // Load deletions
+        $this->_deleted = $this->_loadDelFile();
+    }
+
+    /**
+     * Load detetions file
+     *
+     * Returns bitset or an array depending on bitset extension availability
+     *
+     * @return mixed
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private function _loadDelFile()
+    {
+        if ($this->_delGen == -1) {
+            // There is no delete file for this segment
+            return null;
+        } else if ($this->_delGen == 0) {
+            // It's a segment with pre-2.1 format delete file
+            // Try to load deletions file
+            return $this->_loadPre21DelFile();
+        } else {
+            // It's 2.1+ format deleteions file
+            return $this->_load21DelFile();
+        }
+    }
+
+    /**
+     * Load pre-2.1 detetions file
+     *
+     * Returns bitset or an array depending on bitset extension availability
+     *
+     * @return mixed
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private function _loadPre21DelFile()
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        try {
+            // '.del' files always stored in a separate file
+            // Segment compound is not used
+            $delFile = $this->_directory->getFileObject($this->_name . '.del');
+
+            $byteCount = $delFile->readInt();
+            $byteCount = ceil($byteCount/8);
+            $bitCount  = $delFile->readInt();
+
+            if ($bitCount == 0) {
+                $delBytes = '';
+            } else {
+                $delBytes = $delFile->readBytes($byteCount);
+            }
+
+            if (extension_loaded('bitset')) {
+                return $delBytes;
+            } else {
+                $deletions = array();
+                for ($count = 0; $count < $byteCount; $count++) {
+                    $byte = ord($delBytes[$count]);
+                    for ($bit = 0; $bit < 8; $bit++) {
+                        if ($byte & (1<<$bit)) {
+                            $deletions[$count*8 + $bit] = 1;
+                        }
+                    }
+                }
+
+                return $deletions;
+            }
+        } catch(Zend_Search_Lucene_Exception $e) {
+            if (strpos($e->getMessage(), 'is not readable') === false) {
+                throw $e;
+            }
+            // There is no deletion file
+            $this->_delGen = -1;
+
+            return null;
+        }
+    }
+
+    /**
+     * Load 2.1+ format detetions file
+     *
+     * Returns bitset or an array depending on bitset extension availability
+     *
+     * @return mixed
+     */
+    private function _load21DelFile()
+    {
+        $delFile = $this->_directory->getFileObject($this->_name . '_' . base_convert($this->_delGen, 10, 36) . '.del');
+
+        $format = $delFile->readInt();
+
+        if ($format == (int)0xFFFFFFFF) {
+            if (extension_loaded('bitset')) {
+                $deletions = bitset_empty();
+            } else {
+                $deletions = array();
+            }
+
+            $byteCount = $delFile->readInt();
+            $bitCount  = $delFile->readInt();
+
+            $delFileSize = $this->_directory->fileLength($this->_name . '_' . base_convert($this->_delGen, 10, 36) . '.del');
+            $byteNum = 0;
+
+            do {
+                $dgap = $delFile->readVInt();
+                $nonZeroByte = $delFile->readByte();
+
+                $byteNum += $dgap;
+
+
+                if (extension_loaded('bitset')) {
+                       for ($bit = 0; $bit < 8; $bit++) {
+                           if ($nonZeroByte & (1<<$bit)) {
+                            bitset_incl($deletions, $byteNum*8 + $bit);
+                           }
+                       }
+                    return $deletions;
+                } else {
+                       for ($bit = 0; $bit < 8; $bit++) {
+                           if ($nonZeroByte & (1<<$bit)) {
+                            $deletions[$byteNum*8 + $bit] = 1;
+                           }
+                       }
+                    return (count($deletions) > 0) ? $deletions : null;
+                }
+
+            } while ($delFile->tell() < $delFileSize);
+        } else {
+            // $format is actually byte count
+            $byteCount = ceil($format/8);
+            $bitCount  = $delFile->readInt();
+
+            if ($bitCount == 0) {
+                $delBytes = '';
+            } else {
+                $delBytes = $delFile->readBytes($byteCount);
+            }
+
+            if (extension_loaded('bitset')) {
+                return $delBytes;
+            } else {
+                $deletions = array();
+                for ($count = 0; $count < $byteCount; $count++) {
+                    $byte = ord($delBytes[$count]);
+                    for ($bit = 0; $bit < 8; $bit++) {
+                        if ($byte & (1<<$bit)) {
+                            $deletions[$count*8 + $bit] = 1;
+                        }
+                    }
+                }
+
+                return (count($deletions) > 0) ? $deletions : null;
+            }
+        }
+    }
+
+    /**
+     * Opens index file stoted within compound index file
+     *
+     * @param string $extension
+     * @param boolean $shareHandler
+     * @throws Zend_Search_Lucene_Exception
+     * @return Zend_Search_Lucene_Storage_File
+     */
+    public function openCompoundFile($extension, $shareHandler = true)
+    {
+        if (($extension == '.fdx'  || $extension == '.fdt')  &&  $this->_usesSharedDocStore) {
+            $fdxFName = $this->_sharedDocStoreOptions['segment'] . '.fdx';
+            $fdtFName = $this->_sharedDocStoreOptions['segment'] . '.fdt';
+
+            if (!$this->_sharedDocStoreOptions['isCompound']) {
+                $fdxFile = $this->_directory->getFileObject($fdxFName, $shareHandler);
+                $fdxFile->seek($this->_sharedDocStoreOptions['offset']*8, SEEK_CUR);
+
+                if ($extension == '.fdx') {
+                    // '.fdx' file is requested
+                    return $fdxFile;
+                } else {
+                    // '.fdt' file is requested
+                    $fdtStartOffset = $fdxFile->readLong();
+
+                    $fdtFile = $this->_directory->getFileObject($fdtFName, $shareHandler);
+                    $fdtFile->seek($fdtStartOffset, SEEK_CUR);
+
+                    return $fdtFile;
+                }
+            }
+
+            if( !isset($this->_sharedDocStoreOptions['files'][$fdxFName]) ) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Shared doc storage segment compound file doesn\'t contain '
+                                       . $fdxFName . ' file.' );
+            }
+            if( !isset($this->_sharedDocStoreOptions['files'][$fdtFName]) ) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Shared doc storage segment compound file doesn\'t contain '
+                                       . $fdtFName . ' file.' );
+            }
+
+            // Open shared docstore segment file
+            $cfxFile = $this->_directory->getFileObject($this->_sharedDocStoreOptions['segment'] . '.cfx', $shareHandler);
+            // Seek to the start of '.fdx' file within compound file
+            $cfxFile->seek($this->_sharedDocStoreOptions['files'][$fdxFName]);
+            // Seek to the start of current segment documents section
+            $cfxFile->seek($this->_sharedDocStoreOptions['offset']*8, SEEK_CUR);
+
+            if ($extension == '.fdx') {
+                // '.fdx' file is requested
+                return $cfxFile;
+            } else {
+                // '.fdt' file is requested
+                $fdtStartOffset = $cfxFile->readLong();
+
+                // Seek to the start of '.fdt' file within compound file
+                $cfxFile->seek($this->_sharedDocStoreOptions['files'][$fdtFName]);
+                // Seek to the start of current segment documents section
+                $cfxFile->seek($fdtStartOffset, SEEK_CUR);
+
+                return $fdtFile;
+            }
+        }
+
+        $filename = $this->_name . $extension;
+
+        if (!$this->_isCompound) {
+            return $this->_directory->getFileObject($filename, $shareHandler);
+        }
+
+        if( !isset($this->_segFiles[$filename]) ) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Segment compound file doesn\'t contain '
+                                       . $filename . ' file.' );
+        }
+
+        $file = $this->_directory->getFileObject($this->_name . '.cfs', $shareHandler);
+        $file->seek($this->_segFiles[$filename]);
+        return $file;
+    }
+
+    /**
+     * Get compound file length
+     *
+     * @param string $extension
+     * @return integer
+     */
+    public function compoundFileLength($extension)
+    {
+        if (($extension == '.fdx'  || $extension == '.fdt')  &&  $this->_usesSharedDocStore) {
+            $filename = $this->_sharedDocStoreOptions['segment'] . $extension;
+
+            if (!$this->_sharedDocStoreOptions['isCompound']) {
+                return $this->_directory->fileLength($filename);
+            }
+
+            if( !isset($this->_sharedDocStoreOptions['fileSizes'][$filename]) ) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Shared doc store compound file doesn\'t contain '
+                                           . $filename . ' file.' );
+            }
+
+            return $this->_sharedDocStoreOptions['fileSizes'][$filename];
+        }
+
+
+        $filename = $this->_name . $extension;
+
+        // Try to get common file first
+        if ($this->_directory->fileExists($filename)) {
+            return $this->_directory->fileLength($filename);
+        }
+
+        if( !isset($this->_segFileSizes[$filename]) ) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Index compound file doesn\'t contain '
+                                       . $filename . ' file.' );
+        }
+
+        return $this->_segFileSizes[$filename];
+    }
+
+    /**
+     * Returns field index or -1 if field is not found
+     *
+     * @param string $fieldName
+     * @return integer
+     */
+    public function getFieldNum($fieldName)
+    {
+        foreach( $this->_fields as $field ) {
+            if( $field->name == $fieldName ) {
+                return $field->number;
+            }
+        }
+
+        return -1;
+    }
+
+    /**
+     * Returns field info for specified field
+     *
+     * @param integer $fieldNum
+     * @return Zend_Search_Lucene_Index_FieldInfo
+     */
+    public function getField($fieldNum)
+    {
+        return $this->_fields[$fieldNum];
+    }
+
+    /**
+     * Returns array of fields.
+     * if $indexed parameter is true, then returns only indexed fields.
+     *
+     * @param boolean $indexed
+     * @return array
+     */
+    public function getFields($indexed = false)
+    {
+        $result = array();
+        foreach( $this->_fields as $field ) {
+            if( (!$indexed) || $field->isIndexed ) {
+                $result[ $field->name ] = $field->name;
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Returns array of FieldInfo objects.
+     *
+     * @return array
+     */
+    public function getFieldInfos()
+    {
+        return $this->_fields;
+    }
+
+    /**
+     * Returns actual deletions file generation number.
+     *
+     * @return integer
+     */
+    public function getDelGen()
+    {
+        return $this->_delGen;
+    }
+
+    /**
+     * Returns the total number of documents in this segment (including deleted documents).
+     *
+     * @return integer
+     */
+    public function count()
+    {
+        return $this->_docCount;
+    }
+
+    /**
+     * Returns number of deleted documents.
+     *
+     * @return integer
+     */
+    private function _deletedCount()
+    {
+        if ($this->_deleted === null) {
+            return 0;
+        }
+
+        if (extension_loaded('bitset')) {
+            return count(bitset_to_array($this->_deleted));
+        } else {
+            return count($this->_deleted);
+        }
+    }
+
+    /**
+     * Returns the total number of non-deleted documents in this segment.
+     *
+     * @return integer
+     */
+    public function numDocs()
+    {
+        if ($this->hasDeletions()) {
+            return $this->_docCount - $this->_deletedCount();
+        } else {
+            return $this->_docCount;
+        }
+    }
+
+    /**
+     * Get field position in a fields dictionary
+     *
+     * @param integer $fieldNum
+     * @return integer
+     */
+    private function _getFieldPosition($fieldNum) {
+        // Treat values which are not in a translation table as a 'direct value'
+        return isset($this->_fieldsDicPositions[$fieldNum]) ?
+                           $this->_fieldsDicPositions[$fieldNum] : $fieldNum;
+    }
+
+    /**
+     * Return segment name
+     *
+     * @return string
+     */
+    public function getName()
+    {
+        return $this->_name;
+    }
+
+
+    /**
+     * TermInfo cache
+     *
+     * Size is 1024.
+     * Numbers are used instead of class constants because of performance considerations
+     *
+     * @var array
+     */
+    private $_termInfoCache = array();
+
+    private function _cleanUpTermInfoCache()
+    {
+        // Clean 256 term infos
+        foreach ($this->_termInfoCache as $key => $termInfo) {
+            unset($this->_termInfoCache[$key]);
+
+            // leave 768 last used term infos
+            if (count($this->_termInfoCache) == 768) {
+                break;
+            }
+        }
+    }
+
+    /**
+     * Load terms dictionary index
+     *
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private function _loadDictionaryIndex()
+    {
+        // Check, if index is already serialized
+        if ($this->_directory->fileExists($this->_name . '.sti')) {
+            // Load serialized dictionary index data
+            $stiFile = $this->_directory->getFileObject($this->_name . '.sti');
+            $stiFileData = $stiFile->readBytes($this->_directory->fileLength($this->_name . '.sti'));
+
+            // Load dictionary index data
+            if (($unserializedData = @unserialize($stiFileData)) !== false) {
+                list($this->_termDictionary, $this->_termDictionaryInfos) = $unserializedData;
+                return;
+            }
+        }
+
+        // Load data from .tii file and generate .sti file
+
+        // Prefetch dictionary index data
+        $tiiFile = $this->openCompoundFile('.tii');
+        $tiiFileData = $tiiFile->readBytes($this->compoundFileLength('.tii'));
+
+        // Load dictionary index data
+        list($this->_termDictionary, $this->_termDictionaryInfos) =
+                    Zend_Search_Lucene_Index_DictionaryLoader::load($tiiFileData);
+
+        $stiFileData = serialize(array($this->_termDictionary, $this->_termDictionaryInfos));
+        $stiFile = $this->_directory->createFile($this->_name . '.sti');
+        $stiFile->writeBytes($stiFileData);
+    }
+
+    /**
+     * Scans terms dictionary and returns term info
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @return Zend_Search_Lucene_Index_TermInfo
+     */
+    public function getTermInfo(Zend_Search_Lucene_Index_Term $term)
+    {
+        $termKey = $term->key();
+        if (isset($this->_termInfoCache[$termKey])) {
+            $termInfo = $this->_termInfoCache[$termKey];
+
+            // Move termInfo to the end of cache
+            unset($this->_termInfoCache[$termKey]);
+            $this->_termInfoCache[$termKey] = $termInfo;
+
+            return $termInfo;
+        }
+
+
+        if ($this->_termDictionary === null) {
+            $this->_loadDictionaryIndex();
+        }
+
+        $searchField = $this->getFieldNum($term->field);
+
+        if ($searchField == -1) {
+            return null;
+        }
+        $searchDicField = $this->_getFieldPosition($searchField);
+
+        // search for appropriate value in dictionary
+        $lowIndex = 0;
+        $highIndex = count($this->_termDictionary)-1;
+        while ($highIndex >= $lowIndex) {
+            // $mid = ($highIndex - $lowIndex)/2;
+            $mid = ($highIndex + $lowIndex) >> 1;
+            $midTerm = $this->_termDictionary[$mid];
+
+            $fieldNum = $this->_getFieldPosition($midTerm[0] /* field */);
+            $delta = $searchDicField - $fieldNum;
+            if ($delta == 0) {
+                $delta = strcmp($term->text, $midTerm[1] /* text */);
+            }
+
+            if ($delta < 0) {
+                $highIndex = $mid-1;
+            } elseif ($delta > 0) {
+                $lowIndex  = $mid+1;
+            } else {
+                // return $this->_termDictionaryInfos[$mid]; // We got it!
+                $a = $this->_termDictionaryInfos[$mid];
+                $termInfo = new Zend_Search_Lucene_Index_TermInfo($a[0], $a[1], $a[2], $a[3], $a[4]);
+
+                // Put loaded termInfo into cache
+                $this->_termInfoCache[$termKey] = $termInfo;
+
+                return $termInfo;
+            }
+        }
+
+        if ($highIndex == -1) {
+            // Term is out of the dictionary range
+            return null;
+        }
+
+        $prevPosition = $highIndex;
+        $prevTerm = $this->_termDictionary[$prevPosition];
+        $prevTermInfo = $this->_termDictionaryInfos[$prevPosition];
+
+        $tisFile = $this->openCompoundFile('.tis');
+        $tiVersion = $tisFile->readInt();
+        if ($tiVersion != (int)0xFFFFFFFE /* pre-2.1 format */  &&
+            $tiVersion != (int)0xFFFFFFFD /* 2.1+ format    */) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Wrong TermInfoFile file format');
+        }
+
+        $termCount     = $tisFile->readLong();
+        $indexInterval = $tisFile->readInt();
+        $skipInterval  = $tisFile->readInt();
+        if ($tiVersion == (int)0xFFFFFFFD /* 2.1+ format */) {
+            $maxSkipLevels = $tisFile->readInt();
+        }
+
+        $tisFile->seek($prevTermInfo[4] /* indexPointer */ - (($tiVersion == (int)0xFFFFFFFD)? 24 : 20) /* header size*/, SEEK_CUR);
+
+        $termValue    = $prevTerm[1] /* text */;
+        $termFieldNum = $prevTerm[0] /* field */;
+        $freqPointer = $prevTermInfo[1] /* freqPointer */;
+        $proxPointer = $prevTermInfo[2] /* proxPointer */;
+        for ($count = $prevPosition*$indexInterval + 1;
+             $count <= $termCount &&
+             ( $this->_getFieldPosition($termFieldNum) < $searchDicField ||
+              ($this->_getFieldPosition($termFieldNum) == $searchDicField &&
+               strcmp($termValue, $term->text) < 0) );
+             $count++) {
+            $termPrefixLength = $tisFile->readVInt();
+            $termSuffix       = $tisFile->readString();
+            $termFieldNum     = $tisFile->readVInt();
+            $termValue        = Zend_Search_Lucene_Index_Term::getPrefix($termValue, $termPrefixLength) . $termSuffix;
+
+            $docFreq      = $tisFile->readVInt();
+            $freqPointer += $tisFile->readVInt();
+            $proxPointer += $tisFile->readVInt();
+            if( $docFreq >= $skipInterval ) {
+                $skipOffset = $tisFile->readVInt();
+            } else {
+                $skipOffset = 0;
+            }
+        }
+
+        if ($termFieldNum == $searchField && $termValue == $term->text) {
+            $termInfo = new Zend_Search_Lucene_Index_TermInfo($docFreq, $freqPointer, $proxPointer, $skipOffset);
+        } else {
+            $termInfo = null;
+        }
+
+        // Put loaded termInfo into cache
+        $this->_termInfoCache[$termKey] = $termInfo;
+
+        if (count($this->_termInfoCache) == 1024) {
+            $this->_cleanUpTermInfoCache();
+        }
+
+        return $termInfo;
+    }
+
+    /**
+     * Returns IDs of all the documents containing term.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param integer $shift
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return array
+     */
+    public function termDocs(Zend_Search_Lucene_Index_Term $term, $shift = 0, $docsFilter = null)
+    {
+        $termInfo = $this->getTermInfo($term);
+
+        if (!$termInfo instanceof Zend_Search_Lucene_Index_TermInfo) {
+            if ($docsFilter !== null  &&  $docsFilter instanceof Zend_Search_Lucene_Index_DocsFilter) {
+                $docsFilter->segmentFilters[$this->_name] = array();
+            }
+            return array();
+        }
+
+        $frqFile = $this->openCompoundFile('.frq');
+        $frqFile->seek($termInfo->freqPointer,SEEK_CUR);
+        $docId  = 0;
+        $result = array();
+
+        if ($docsFilter !== null) {
+            if (!$docsFilter instanceof Zend_Search_Lucene_Index_DocsFilter) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Documents filter must be an instance of Zend_Search_Lucene_Index_DocsFilter or null.');
+            }
+
+            if (isset($docsFilter->segmentFilters[$this->_name])) {
+                // Filter already has some data for the current segment
+
+                // Make short name for the filter (which doesn't need additional dereferencing)
+                $filter = &$docsFilter->segmentFilters[$this->_name];
+
+                // Check if filter is not empty
+                if (count($filter) == 0) {
+                    return array();
+                }
+
+                if ($this->_docCount/count($filter) < self::FULL_SCAN_VS_FETCH_BOUNDARY) {
+                    // Perform fetching
+// ---------------------------------------------------------------
+                    $updatedFilterData = array();
+
+                    for( $count=0; $count < $termInfo->docFreq; $count++ ) {
+                        $docDelta = $frqFile->readVInt();
+                        if( $docDelta % 2 == 1 ) {
+                            $docId += ($docDelta-1)/2;
+                        } else {
+                            $docId += $docDelta/2;
+                            // read freq
+                            $frqFile->readVInt();
+                        }
+
+                        if (isset($filter[$docId])) {
+                           $result[] = $shift + $docId;
+                           $updatedFilterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                        }
+                    }
+                    $docsFilter->segmentFilters[$this->_name] = $updatedFilterData;
+// ---------------------------------------------------------------
+                } else {
+                    // Perform full scan
+                    $updatedFilterData = array();
+
+                    for( $count=0; $count < $termInfo->docFreq; $count++ ) {
+                        $docDelta = $frqFile->readVInt();
+                        if( $docDelta % 2 == 1 ) {
+                            $docId += ($docDelta-1)/2;
+                        } else {
+                            $docId += $docDelta/2;
+                            // read freq
+                            $frqFile->readVInt();
+                        }
+
+                        if (isset($filter[$docId])) {
+                           $result[] = $shift + $docId;
+                           $updatedFilterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                        }
+                    }
+                    $docsFilter->segmentFilters[$this->_name] = $updatedFilterData;
+                }
+            } else {
+                // Filter is present, but doesn't has data for the current segment yet
+                $filterData = array();
+                for( $count=0; $count < $termInfo->docFreq; $count++ ) {
+                    $docDelta = $frqFile->readVInt();
+                    if( $docDelta % 2 == 1 ) {
+                        $docId += ($docDelta-1)/2;
+                    } else {
+                        $docId += $docDelta/2;
+                        // read freq
+                        $frqFile->readVInt();
+                    }
+
+                    $result[] = $shift + $docId;
+                    $filterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                }
+                $docsFilter->segmentFilters[$this->_name] = $filterData;
+            }
+        } else {
+            for( $count=0; $count < $termInfo->docFreq; $count++ ) {
+                $docDelta = $frqFile->readVInt();
+                if( $docDelta % 2 == 1 ) {
+                    $docId += ($docDelta-1)/2;
+                } else {
+                    $docId += $docDelta/2;
+                    // read freq
+                    $frqFile->readVInt();
+                }
+
+                $result[] = $shift + $docId;
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Returns term freqs array.
+     * Result array structure: array(docId => freq, ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param integer $shift
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return Zend_Search_Lucene_Index_TermInfo
+     */
+    public function termFreqs(Zend_Search_Lucene_Index_Term $term, $shift = 0, $docsFilter = null)
+    {
+        $termInfo = $this->getTermInfo($term);
+
+        if (!$termInfo instanceof Zend_Search_Lucene_Index_TermInfo) {
+            if ($docsFilter !== null  &&  $docsFilter instanceof Zend_Search_Lucene_Index_DocsFilter) {
+                $docsFilter->segmentFilters[$this->_name] = array();
+            }
+            return array();
+        }
+
+        $frqFile = $this->openCompoundFile('.frq');
+        $frqFile->seek($termInfo->freqPointer,SEEK_CUR);
+        $result = array();
+        $docId = 0;
+
+        $result = array();
+
+        if ($docsFilter !== null) {
+            if (!$docsFilter instanceof Zend_Search_Lucene_Index_DocsFilter) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Documents filter must be an instance of Zend_Search_Lucene_Index_DocsFilter or null.');
+            }
+
+            if (isset($docsFilter->segmentFilters[$this->_name])) {
+                // Filter already has some data for the current segment
+
+                // Make short name for the filter (which doesn't need additional dereferencing)
+                $filter = &$docsFilter->segmentFilters[$this->_name];
+
+                // Check if filter is not empty
+                if (count($filter) == 0) {
+                    return array();
+                }
+
+
+                if ($this->_docCount/count($filter) < self::FULL_SCAN_VS_FETCH_BOUNDARY) {
+                    // Perform fetching
+// ---------------------------------------------------------------
+                    $updatedFilterData = array();
+
+                    for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                        $docDelta = $frqFile->readVInt();
+                        if ($docDelta % 2 == 1) {
+                            $docId += ($docDelta-1)/2;
+                            if (isset($filter[$docId])) {
+                                $result[$shift + $docId] = 1;
+                                $updatedFilterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                            }
+                        } else {
+                            $docId += $docDelta/2;
+                            if (isset($filter[$docId])) {
+                                $result[$shift + $docId] = $frqFile->readVInt();
+                                $updatedFilterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                            }
+                        }
+                    }
+                    $docsFilter->segmentFilters[$this->_name] = $updatedFilterData;
+// ---------------------------------------------------------------
+                } else {
+                    // Perform full scan
+                    $updatedFilterData = array();
+
+                    for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                        $docDelta = $frqFile->readVInt();
+                        if ($docDelta % 2 == 1) {
+                            $docId += ($docDelta-1)/2;
+                            if (isset($filter[$docId])) {
+                                $result[$shift + $docId] = 1;
+                                $updatedFilterData[$docId] = 1; // 1 is just some constant value, so we don't need additional var dereference here
+                            }
+                        } else {
+                            $docId += $docDelta/2;
+                            if (isset($filter[$docId])) {
+                                $result[$shift + $docId] = $frqFile->readVInt();
+                                $updatedFilterData[$docId] = 1; // 1 is just some constant value, so we don't need additional var dereference here
+                            }
+                        }
+                    }
+                    $docsFilter->segmentFilters[$this->_name] = $updatedFilterData;
+                }
+            } else {
+                // Filter doesn't has data for current segment
+                $filterData = array();
+
+                for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                    $docDelta = $frqFile->readVInt();
+                    if ($docDelta % 2 == 1) {
+                        $docId += ($docDelta-1)/2;
+                        $result[$shift + $docId] = 1;
+                        $filterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                    } else {
+                        $docId += $docDelta/2;
+                        $result[$shift + $docId] = $frqFile->readVInt();
+                        $filterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                    }
+                }
+
+                $docsFilter->segmentFilters[$this->_name] = $filterData;
+            }
+        } else {
+            for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                $docDelta = $frqFile->readVInt();
+                if ($docDelta % 2 == 1) {
+                    $docId += ($docDelta-1)/2;
+                    $result[$shift + $docId] = 1;
+                } else {
+                    $docId += $docDelta/2;
+                    $result[$shift + $docId] = $frqFile->readVInt();
+                }
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Returns term positions array.
+     * Result array structure: array(docId => array(pos1, pos2, ...), ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param integer $shift
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return Zend_Search_Lucene_Index_TermInfo
+     */
+    public function termPositions(Zend_Search_Lucene_Index_Term $term, $shift = 0, $docsFilter = null)
+    {
+        $termInfo = $this->getTermInfo($term);
+
+        if (!$termInfo instanceof Zend_Search_Lucene_Index_TermInfo) {
+            if ($docsFilter !== null  &&  $docsFilter instanceof Zend_Search_Lucene_Index_DocsFilter) {
+                $docsFilter->segmentFilters[$this->_name] = array();
+            }
+            return array();
+        }
+
+        $frqFile = $this->openCompoundFile('.frq');
+        $frqFile->seek($termInfo->freqPointer,SEEK_CUR);
+
+        $docId = 0;
+        $freqs = array();
+
+
+        if ($docsFilter !== null) {
+            if (!$docsFilter instanceof Zend_Search_Lucene_Index_DocsFilter) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Documents filter must be an instance of Zend_Search_Lucene_Index_DocsFilter or null.');
+            }
+
+            if (isset($docsFilter->segmentFilters[$this->_name])) {
+                // Filter already has some data for the current segment
+
+                // Make short name for the filter (which doesn't need additional dereferencing)
+                $filter = &$docsFilter->segmentFilters[$this->_name];
+
+                // Check if filter is not empty
+                if (count($filter) == 0) {
+                    return array();
+                }
+
+                if ($this->_docCount/count($filter) < self::FULL_SCAN_VS_FETCH_BOUNDARY) {
+                    // Perform fetching
+// ---------------------------------------------------------------
+                    for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                        $docDelta = $frqFile->readVInt();
+                        if ($docDelta % 2 == 1) {
+                            $docId += ($docDelta-1)/2;
+                            $freqs[$docId] = 1;
+                        } else {
+                            $docId += $docDelta/2;
+                            $freqs[$docId] = $frqFile->readVInt();
+                        }
+                    }
+
+                    $updatedFilterData = array();
+                    $result = array();
+                    $prxFile = $this->openCompoundFile('.prx');
+                    $prxFile->seek($termInfo->proxPointer, SEEK_CUR);
+                    foreach ($freqs as $docId => $freq) {
+                        $termPosition = 0;
+                        $positions = array();
+
+                        // we have to read .prx file to get right position for next doc
+                        // even filter doesn't match current document
+                        for ($count = 0; $count < $freq; $count++ ) {
+                            $termPosition += $prxFile->readVInt();
+                            $positions[] = $termPosition;
+                        }
+
+                        // Include into updated filter and into result only if doc is matched by filter
+                        if (isset($filter[$docId])) {
+                            $updatedFilterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                            $result[$shift + $docId] = $positions;
+                        }
+                    }
+
+                    $docsFilter->segmentFilters[$this->_name] = $updatedFilterData;
+// ---------------------------------------------------------------
+                } else {
+                    // Perform full scan
+                    for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                        $docDelta = $frqFile->readVInt();
+                        if ($docDelta % 2 == 1) {
+                            $docId += ($docDelta-1)/2;
+                            $freqs[$docId] = 1;
+                        } else {
+                            $docId += $docDelta/2;
+                            $freqs[$docId] = $frqFile->readVInt();
+                        }
+                    }
+
+                    $updatedFilterData = array();
+                    $result = array();
+                    $prxFile = $this->openCompoundFile('.prx');
+                    $prxFile->seek($termInfo->proxPointer, SEEK_CUR);
+                    foreach ($freqs as $docId => $freq) {
+                        $termPosition = 0;
+                        $positions = array();
+
+                        // we have to read .prx file to get right position for next doc
+                        // even filter doesn't match current document
+                        for ($count = 0; $count < $freq; $count++ ) {
+                            $termPosition += $prxFile->readVInt();
+                            $positions[] = $termPosition;
+                        }
+
+                        // Include into updated filter and into result only if doc is matched by filter
+                        if (isset($filter[$docId])) {
+                            $updatedFilterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+                            $result[$shift + $docId] = $positions;
+                        }
+                    }
+
+                    $docsFilter->segmentFilters[$this->_name] = $updatedFilterData;
+                }
+            } else {
+                // Filter doesn't has data for current segment
+                for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                    $docDelta = $frqFile->readVInt();
+                    if ($docDelta % 2 == 1) {
+                        $docId += ($docDelta-1)/2;
+                        $freqs[$docId] = 1;
+                    } else {
+                        $docId += $docDelta/2;
+                        $freqs[$docId] = $frqFile->readVInt();
+                    }
+                }
+
+                $filterData = array();
+                $result = array();
+                $prxFile = $this->openCompoundFile('.prx');
+                $prxFile->seek($termInfo->proxPointer, SEEK_CUR);
+                foreach ($freqs as $docId => $freq) {
+                    $filterData[$docId] = 1; // 1 is just a some constant value, so we don't need additional var dereference here
+
+                    $termPosition = 0;
+                    $positions = array();
+
+                    for ($count = 0; $count < $freq; $count++ ) {
+                        $termPosition += $prxFile->readVInt();
+                        $positions[] = $termPosition;
+                    }
+
+                    $result[$shift + $docId] = $positions;
+                }
+
+                $docsFilter->segmentFilters[$this->_name] = $filterData;
+            }
+        } else {
+            for ($count = 0; $count < $termInfo->docFreq; $count++) {
+                $docDelta = $frqFile->readVInt();
+                if ($docDelta % 2 == 1) {
+                    $docId += ($docDelta-1)/2;
+                    $freqs[$docId] = 1;
+                } else {
+                    $docId += $docDelta/2;
+                    $freqs[$docId] = $frqFile->readVInt();
+                }
+            }
+
+            $result = array();
+            $prxFile = $this->openCompoundFile('.prx');
+            $prxFile->seek($termInfo->proxPointer, SEEK_CUR);
+            foreach ($freqs as $docId => $freq) {
+                $termPosition = 0;
+                $positions = array();
+
+                for ($count = 0; $count < $freq; $count++ ) {
+                    $termPosition += $prxFile->readVInt();
+                    $positions[] = $termPosition;
+                }
+
+                $result[$shift + $docId] = $positions;
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Load normalizatin factors from an index file
+     *
+     * @param integer $fieldNum
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private function _loadNorm($fieldNum)
+    {
+        if ($this->_hasSingleNormFile) {
+            $normfFile = $this->openCompoundFile('.nrm');
+
+            $header              = $normfFile->readBytes(3);
+            $headerFormatVersion = $normfFile->readByte();
+
+            if ($header != 'NRM'  ||  $headerFormatVersion != (int)0xFF) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new  Zend_Search_Lucene_Exception('Wrong norms file format.');
+            }
+
+            foreach ($this->_fields as $fNum => $fieldInfo) {
+                if ($fieldInfo->isIndexed) {
+                    $this->_norms[$fNum] = $normfFile->readBytes($this->_docCount);
+                }
+            }
+        } else {
+            $fFile = $this->openCompoundFile('.f' . $fieldNum);
+            $this->_norms[$fieldNum] = $fFile->readBytes($this->_docCount);
+        }
+    }
+
+    /**
+     * Returns normalization factor for specified documents
+     *
+     * @param integer $id
+     * @param string $fieldName
+     * @return float
+     */
+    public function norm($id, $fieldName)
+    {
+        $fieldNum = $this->getFieldNum($fieldName);
+
+        if ( !($this->_fields[$fieldNum]->isIndexed) ) {
+            return null;
+        }
+
+        if (!isset($this->_norms[$fieldNum])) {
+            $this->_loadNorm($fieldNum);
+        }
+
+        return Zend_Search_Lucene_Search_Similarity::decodeNorm( ord($this->_norms[$fieldNum][$id]) );
+    }
+
+    /**
+     * Returns norm vector, encoded in a byte string
+     *
+     * @param string $fieldName
+     * @return string
+     */
+    public function normVector($fieldName)
+    {
+        $fieldNum = $this->getFieldNum($fieldName);
+
+        if ($fieldNum == -1  ||  !($this->_fields[$fieldNum]->isIndexed)) {
+            $similarity = Zend_Search_Lucene_Search_Similarity::getDefault();
+
+            return str_repeat(chr($similarity->encodeNorm( $similarity->lengthNorm($fieldName, 0) )),
+                              $this->_docCount);
+        }
+
+        if (!isset($this->_norms[$fieldNum])) {
+            $this->_loadNorm($fieldNum);
+        }
+
+        return $this->_norms[$fieldNum];
+    }
+
+
+    /**
+     * Returns true if any documents have been deleted from this index segment.
+     *
+     * @return boolean
+     */
+    public function hasDeletions()
+    {
+        return $this->_deleted !== null;
+    }
+
+
+    /**
+     * Returns true if segment has single norms file.
+     *
+     * @return boolean
+     */
+    public function hasSingleNormFile()
+    {
+        return $this->_hasSingleNormFile ? true : false;
+    }
+
+    /**
+     * Returns true if segment is stored using compound segment file.
+     *
+     * @return boolean
+     */
+    public function isCompound()
+    {
+        return $this->_isCompound;
+    }
+
+    /**
+     * Deletes a document from the index segment.
+     * $id is an internal document id
+     *
+     * @param integer
+     */
+    public function delete($id)
+    {
+        $this->_deletedDirty = true;
+
+        if (extension_loaded('bitset')) {
+            if ($this->_deleted === null) {
+                $this->_deleted = bitset_empty($id);
+            }
+            bitset_incl($this->_deleted, $id);
+        } else {
+            if ($this->_deleted === null) {
+                $this->_deleted = array();
+            }
+
+            $this->_deleted[$id] = 1;
+        }
+    }
+
+    /**
+     * Checks, that document is deleted
+     *
+     * @param integer
+     * @return boolean
+     */
+    public function isDeleted($id)
+    {
+        if ($this->_deleted === null) {
+            return false;
+        }
+
+        if (extension_loaded('bitset')) {
+            return bitset_in($this->_deleted, $id);
+        } else {
+            return isset($this->_deleted[$id]);
+        }
+    }
+
+    /**
+     * Detect latest delete generation
+     *
+     * Is actualy used from writeChanges() method or from the constructor if it's invoked from
+     * Index writer. In both cases index write lock is already obtained, so we shouldn't care
+     * about it
+     *
+     * @return integer
+     */
+    private function _detectLatestDelGen()
+    {
+        $delFileList = array();
+        foreach ($this->_directory->fileList() as $file) {
+            if ($file == $this->_name . '.del') {
+                // Matches <segment_name>.del file name
+                $delFileList[] = 0;
+            } else if (preg_match('/^' . $this->_name . '_([a-zA-Z0-9]+)\.del$/i', $file, $matches)) {
+                // Matches <segment_name>_NNN.del file names
+                $delFileList[] = (int)base_convert($matches[1], 36, 10);
+            }
+        }
+
+        if (count($delFileList) == 0) {
+            // There is no deletions file for current segment in the directory
+            // Set deletions file generation number to 1
+            return -1;
+        } else {
+            // There are some deletions files for current segment in the directory
+            // Set deletions file generation number to the highest nuber
+            return max($delFileList);
+        }
+    }
+
+    /**
+     * Write changes if it's necessary.
+     *
+     * This method must be invoked only from the Writer _updateSegments() method,
+     * so index Write lock has to be already obtained.
+     *
+     * @internal
+     * @throws Zend_Search_Lucene_Exceptions
+     */
+    public function writeChanges()
+    {
+        // Get new generation number
+        $latestDelGen = $this->_detectLatestDelGen();
+
+        if (!$this->_deletedDirty) {
+               // There was no deletions by current process
+
+            if ($latestDelGen == $this->_delGen) {
+               // Delete file hasn't been updated by any concurrent process
+               return;
+            } else if ($latestDelGen > $this->_delGen) {
+               // Delete file has been updated by some concurrent process
+               // Reload deletions file
+               $this->_delGen  = $latestDelGen;
+               $this->_deleted = $this->_loadDelFile();
+
+               return;
+            } else {
+               require_once 'Zend/Search/Lucene/Exception.php';
+               throw new Zend_Search_Lucene_Exception('Delete file processing workflow is corrupted for the segment \'' . $this->_name . '\'.');
+            }
+        }
+
+        if ($latestDelGen > $this->_delGen) {
+               // Merge current deletions with latest deletions file
+               $this->_delGen = $latestDelGen;
+
+               $latestDelete = $this->_loadDelFile();
+
+               if (extension_loaded('bitset')) {
+                       $this->_deleted = bitset_union($this->_deleted, $latestDelete);
+               } else {
+                       $this->_deleted += $latestDelete;
+               }
+        }
+
+        if (extension_loaded('bitset')) {
+            $delBytes = $this->_deleted;
+            $bitCount = count(bitset_to_array($delBytes));
+        } else {
+            $byteCount = floor($this->_docCount/8)+1;
+            $delBytes = str_repeat(chr(0), $byteCount);
+            for ($count = 0; $count < $byteCount; $count++) {
+                $byte = 0;
+                for ($bit = 0; $bit < 8; $bit++) {
+                    if (isset($this->_deleted[$count*8 + $bit])) {
+                        $byte |= (1<<$bit);
+                    }
+                }
+                $delBytes[$count] = chr($byte);
+            }
+            $bitCount = count($this->_deleted);
+        }
+
+        if ($this->_delGen == -1) {
+            // Set delete file generation number to 1
+            $this->_delGen = 1;
+        } else {
+            // Increase delete file generation number by 1
+            $this->_delGen++;
+        }
+
+        $delFile = $this->_directory->createFile($this->_name . '_' . base_convert($this->_delGen, 10, 36) . '.del');
+        $delFile->writeInt($this->_docCount);
+        $delFile->writeInt($bitCount);
+        $delFile->writeBytes($delBytes);
+
+        $this->_deletedDirty = false;
+    }
+
+
+    /**
+     * Term Dictionary File object for stream like terms reading
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    private $_tisFile = null;
+
+    /**
+     * Actual offset of the .tis file data
+     *
+     * @var integer
+     */
+    private $_tisFileOffset;
+
+    /**
+     * Frequencies File object for stream like terms reading
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    private $_frqFile = null;
+
+    /**
+     * Actual offset of the .frq file data
+     *
+     * @var integer
+     */
+    private $_frqFileOffset;
+
+    /**
+     * Positions File object for stream like terms reading
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    private $_prxFile = null;
+
+    /**
+     * Actual offset of the .prx file in the compound file
+     *
+     * @var integer
+     */
+    private $_prxFileOffset;
+
+
+    /**
+     * Actual number of terms in term stream
+     *
+     * @var integer
+     */
+    private $_termCount = 0;
+
+    /**
+     * Overall number of terms in term stream
+     *
+     * @var integer
+     */
+    private $_termNum = 0;
+
+    /**
+     * Segment index interval
+     *
+     * @var integer
+     */
+    private $_indexInterval;
+
+    /**
+     * Segment skip interval
+     *
+     * @var integer
+     */
+    private $_skipInterval;
+
+    /**
+     * Last TermInfo in a terms stream
+     *
+     * @var Zend_Search_Lucene_Index_TermInfo
+     */
+    private $_lastTermInfo = null;
+
+    /**
+     * Last Term in a terms stream
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_lastTerm = null;
+
+    /**
+     * Map of the document IDs
+     * Used to get new docID after removing deleted documents.
+     * It's not very effective from memory usage point of view,
+     * but much more faster, then other methods
+     *
+     * @var array|null
+     */
+    private $_docMap = null;
+
+    /**
+     * An array of all term positions in the documents.
+     * Array structure: array( docId => array( pos1, pos2, ...), ...)
+     *
+     * Is set to null if term positions loading has to be skipped
+     *
+     * @var array|null
+     */
+    private $_lastTermPositions;
+
+
+    /**
+     * Terms scan mode
+     *
+     * Values:
+     *
+     * self::SM_TERMS_ONLY - terms are scanned, no additional info is retrieved
+     * self::SM_FULL_INFO  - terms are scanned, frequency and position info is retrieved
+     * self::SM_MERGE_INFO - terms are scanned, frequency and position info is retrieved
+     *                       document numbers are compacted (shifted if segment has deleted documents)
+     *
+     * @var integer
+     */
+    private $_termsScanMode;
+
+    /** Scan modes */
+    const SM_TERMS_ONLY = 0;    // terms are scanned, no additional info is retrieved
+    const SM_FULL_INFO  = 1;    // terms are scanned, frequency and position info is retrieved
+    const SM_MERGE_INFO = 2;    // terms are scanned, frequency and position info is retrieved
+                                // document numbers are compacted (shifted if segment contains deleted documents)
+
+    /**
+     * Reset terms stream
+     *
+     * $startId - id for the fist document
+     * $compact - remove deleted documents
+     *
+     * Returns start document id for the next segment
+     *
+     * @param integer $startId
+     * @param integer $mode
+     * @throws Zend_Search_Lucene_Exception
+     * @return integer
+     */
+    public function resetTermsStream(/** $startId = 0, $mode = self::SM_TERMS_ONLY */)
+    {
+       /**
+        * SegmentInfo->resetTermsStream() method actually takes two optional parameters:
+        *   $startId (default value is 0)
+        *   $mode (default value is self::SM_TERMS_ONLY)
+        */
+       $argList = func_get_args();
+       if (count($argList) > 2) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Wrong number of arguments');
+       } else if (count($argList) == 2) {
+               $startId = $argList[0];
+               $mode    = $argList[1];
+       } else if (count($argList) == 1) {
+            $startId = $argList[0];
+            $mode    = self::SM_TERMS_ONLY;
+        } else {
+            $startId = 0;
+            $mode    = self::SM_TERMS_ONLY;
+        }
+
+        if ($this->_tisFile !== null) {
+            $this->_tisFile = null;
+        }
+
+        $this->_tisFile = $this->openCompoundFile('.tis', false);
+        $this->_tisFileOffset = $this->_tisFile->tell();
+
+        $tiVersion = $this->_tisFile->readInt();
+        if ($tiVersion != (int)0xFFFFFFFE /* pre-2.1 format */  &&
+            $tiVersion != (int)0xFFFFFFFD /* 2.1+ format    */) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Wrong TermInfoFile file format');
+        }
+
+        $this->_termCount     =
+              $this->_termNum = $this->_tisFile->readLong(); // Read terms count
+        $this->_indexInterval = $this->_tisFile->readInt();  // Read Index interval
+        $this->_skipInterval  = $this->_tisFile->readInt();  // Read skip interval
+        if ($tiVersion == (int)0xFFFFFFFD /* 2.1+ format */) {
+            $maxSkipLevels = $this->_tisFile->readInt();
+        }
+
+        if ($this->_frqFile !== null) {
+            $this->_frqFile = null;
+        }
+        if ($this->_prxFile !== null) {
+            $this->_prxFile = null;
+        }
+        $this->_docMap = array();
+
+        $this->_lastTerm          = new Zend_Search_Lucene_Index_Term('', -1);
+        $this->_lastTermInfo      = new Zend_Search_Lucene_Index_TermInfo(0, 0, 0, 0);
+        $this->_lastTermPositions = null;
+
+        $this->_termsScanMode = $mode;
+
+        switch ($mode) {
+            case self::SM_TERMS_ONLY:
+                // Do nothing
+                break;
+
+            case self::SM_FULL_INFO:
+                // break intentionally omitted
+            case self::SM_MERGE_INFO:
+                $this->_frqFile = $this->openCompoundFile('.frq', false);
+                $this->_frqFileOffset = $this->_frqFile->tell();
+
+                $this->_prxFile = $this->openCompoundFile('.prx', false);
+                $this->_prxFileOffset = $this->_prxFile->tell();
+
+                for ($count = 0; $count < $this->_docCount; $count++) {
+                    if (!$this->isDeleted($count)) {
+                        $this->_docMap[$count] = $startId + (($mode == self::SM_MERGE_INFO) ? count($this->_docMap) : $count);
+                    }
+                }
+                break;
+
+            default:
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Wrong terms scaning mode specified.');
+                break;
+        }
+
+
+        $this->nextTerm();
+        return $startId + (($mode == self::SM_MERGE_INFO) ? count($this->_docMap) : $this->_docCount);
+    }
+
+
+    /**
+     * Skip terms stream up to specified term preffix.
+     *
+     * Prefix contains fully specified field info and portion of searched term
+     *
+     * @param Zend_Search_Lucene_Index_Term $prefix
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function skipTo(Zend_Search_Lucene_Index_Term $prefix)
+    {
+        if ($this->_termDictionary === null) {
+            $this->_loadDictionaryIndex();
+        }
+
+        $searchField = $this->getFieldNum($prefix->field);
+
+        if ($searchField == -1) {
+            /**
+             * Field is not presented in this segment
+             * Go to the end of dictionary
+             */
+            $this->_tisFile = null;
+            $this->_frqFile = null;
+            $this->_prxFile = null;
+
+            $this->_lastTerm          = null;
+            $this->_lastTermInfo      = null;
+            $this->_lastTermPositions = null;
+
+            return;
+        }
+        $searchDicField = $this->_getFieldPosition($searchField);
+
+        // search for appropriate value in dictionary
+        $lowIndex = 0;
+        $highIndex = count($this->_termDictionary)-1;
+        while ($highIndex >= $lowIndex) {
+            // $mid = ($highIndex - $lowIndex)/2;
+            $mid = ($highIndex + $lowIndex) >> 1;
+            $midTerm = $this->_termDictionary[$mid];
+
+            $fieldNum = $this->_getFieldPosition($midTerm[0] /* field */);
+            $delta = $searchDicField - $fieldNum;
+            if ($delta == 0) {
+                $delta = strcmp($prefix->text, $midTerm[1] /* text */);
+            }
+
+            if ($delta < 0) {
+                $highIndex = $mid-1;
+            } elseif ($delta > 0) {
+                $lowIndex  = $mid+1;
+            } else {
+                // We have reached term we are looking for
+                break;
+            }
+        }
+
+        if ($highIndex == -1) {
+            // Term is out of the dictionary range
+            $this->_tisFile = null;
+            $this->_frqFile = null;
+            $this->_prxFile = null;
+
+            $this->_lastTerm          = null;
+            $this->_lastTermInfo      = null;
+            $this->_lastTermPositions = null;
+
+            return;
+        }
+
+        $prevPosition = $highIndex;
+        $prevTerm = $this->_termDictionary[$prevPosition];
+        $prevTermInfo = $this->_termDictionaryInfos[$prevPosition];
+
+        if ($this->_tisFile === null) {
+            // The end of terms stream is reached and terms dictionary file is closed
+            // Perform mini-reset operation
+            $this->_tisFile = $this->openCompoundFile('.tis', false);
+
+            if ($this->_termsScanMode == self::SM_FULL_INFO  ||  $this->_termsScanMode == self::SM_MERGE_INFO) {
+                $this->_frqFile = $this->openCompoundFile('.frq', false);
+                $this->_prxFile = $this->openCompoundFile('.prx', false);
+            }
+        }
+        $this->_tisFile->seek($this->_tisFileOffset + $prevTermInfo[4], SEEK_SET);
+
+        $this->_lastTerm     = new Zend_Search_Lucene_Index_Term($prevTerm[1] /* text */,
+                                                                 ($prevTerm[0] == -1) ? '' : $this->_fields[$prevTerm[0] /* field */]->name);
+        $this->_lastTermInfo = new Zend_Search_Lucene_Index_TermInfo($prevTermInfo[0] /* docFreq */,
+                                                                     $prevTermInfo[1] /* freqPointer */,
+                                                                     $prevTermInfo[2] /* proxPointer */,
+                                                                     $prevTermInfo[3] /* skipOffset */);
+        $this->_termCount  =  $this->_termNum - $prevPosition*$this->_indexInterval;
+
+        if ($highIndex == 0) {
+            // skip start entry
+            $this->nextTerm();
+        } else if ($prefix->field == $this->_lastTerm->field  &&  $prefix->text  == $this->_lastTerm->text) {
+            // We got exact match in the dictionary index
+
+            if ($this->_termsScanMode == self::SM_FULL_INFO  ||  $this->_termsScanMode == self::SM_MERGE_INFO) {
+                $this->_lastTermPositions = array();
+
+                $this->_frqFile->seek($this->_lastTermInfo->freqPointer + $this->_frqFileOffset, SEEK_SET);
+                $freqs = array();   $docId = 0;
+                for( $count = 0; $count < $this->_lastTermInfo->docFreq; $count++ ) {
+                    $docDelta = $this->_frqFile->readVInt();
+                    if( $docDelta % 2 == 1 ) {
+                        $docId += ($docDelta-1)/2;
+                        $freqs[ $docId ] = 1;
+                    } else {
+                        $docId += $docDelta/2;
+                        $freqs[ $docId ] = $this->_frqFile->readVInt();
+                    }
+                }
+
+                $this->_prxFile->seek($this->_lastTermInfo->proxPointer + $this->_prxFileOffset, SEEK_SET);
+                foreach ($freqs as $docId => $freq) {
+                    $termPosition = 0;  $positions = array();
+
+                    for ($count = 0; $count < $freq; $count++ ) {
+                        $termPosition += $this->_prxFile->readVInt();
+                        $positions[] = $termPosition;
+                    }
+
+                    if (isset($this->_docMap[$docId])) {
+                        $this->_lastTermPositions[$this->_docMap[$docId]] = $positions;
+                    }
+                }
+            }
+
+            return;
+        }
+
+        // Search term matching specified prefix
+        while ($this->_lastTerm !== null) {
+            if ( strcmp($this->_lastTerm->field, $prefix->field) > 0  ||
+                 ($prefix->field == $this->_lastTerm->field  &&  strcmp($this->_lastTerm->text, $prefix->text) >= 0) ) {
+                    // Current term matches or greate than the pattern
+                    return;
+            }
+
+            $this->nextTerm();
+        }
+    }
+
+
+    /**
+     * Scans terms dictionary and returns next term
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function nextTerm()
+    {
+        if ($this->_tisFile === null  ||  $this->_termCount == 0) {
+            $this->_lastTerm          = null;
+            $this->_lastTermInfo      = null;
+            $this->_lastTermPositions = null;
+            $this->_docMap            = null;
+
+            // may be necessary for "empty" segment
+            $this->_tisFile = null;
+            $this->_frqFile = null;
+            $this->_prxFile = null;
+
+            return null;
+        }
+
+        $termPrefixLength = $this->_tisFile->readVInt();
+        $termSuffix       = $this->_tisFile->readString();
+        $termFieldNum     = $this->_tisFile->readVInt();
+        $termValue        = Zend_Search_Lucene_Index_Term::getPrefix($this->_lastTerm->text, $termPrefixLength) . $termSuffix;
+
+        $this->_lastTerm = new Zend_Search_Lucene_Index_Term($termValue, $this->_fields[$termFieldNum]->name);
+
+        $docFreq     = $this->_tisFile->readVInt();
+        $freqPointer = $this->_lastTermInfo->freqPointer + $this->_tisFile->readVInt();
+        $proxPointer = $this->_lastTermInfo->proxPointer + $this->_tisFile->readVInt();
+        if ($docFreq >= $this->_skipInterval) {
+            $skipOffset = $this->_tisFile->readVInt();
+        } else {
+            $skipOffset = 0;
+        }
+
+        $this->_lastTermInfo = new Zend_Search_Lucene_Index_TermInfo($docFreq, $freqPointer, $proxPointer, $skipOffset);
+
+
+        if ($this->_termsScanMode == self::SM_FULL_INFO  ||  $this->_termsScanMode == self::SM_MERGE_INFO) {
+            $this->_lastTermPositions = array();
+
+            $this->_frqFile->seek($this->_lastTermInfo->freqPointer + $this->_frqFileOffset, SEEK_SET);
+            $freqs = array();   $docId = 0;
+            for( $count = 0; $count < $this->_lastTermInfo->docFreq; $count++ ) {
+                $docDelta = $this->_frqFile->readVInt();
+                if( $docDelta % 2 == 1 ) {
+                    $docId += ($docDelta-1)/2;
+                    $freqs[ $docId ] = 1;
+                } else {
+                    $docId += $docDelta/2;
+                    $freqs[ $docId ] = $this->_frqFile->readVInt();
+                }
+            }
+
+            $this->_prxFile->seek($this->_lastTermInfo->proxPointer + $this->_prxFileOffset, SEEK_SET);
+            foreach ($freqs as $docId => $freq) {
+                $termPosition = 0;  $positions = array();
+
+                for ($count = 0; $count < $freq; $count++ ) {
+                    $termPosition += $this->_prxFile->readVInt();
+                    $positions[] = $termPosition;
+                }
+
+                if (isset($this->_docMap[$docId])) {
+                    $this->_lastTermPositions[$this->_docMap[$docId]] = $positions;
+                }
+            }
+        }
+
+        $this->_termCount--;
+        if ($this->_termCount == 0) {
+            $this->_tisFile = null;
+            $this->_frqFile = null;
+            $this->_prxFile = null;
+        }
+
+        return $this->_lastTerm;
+    }
+
+    /**
+     * Close terms stream
+     *
+     * Should be used for resources clean up if stream is not read up to the end
+     */
+    public function closeTermsStream()
+    {
+        $this->_tisFile = null;
+        $this->_frqFile = null;
+        $this->_prxFile = null;
+
+        $this->_lastTerm          = null;
+        $this->_lastTermInfo      = null;
+        $this->_lastTermPositions = null;
+
+        $this->_docMap            = null;
+    }
+
+
+    /**
+     * Returns term in current position
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function currentTerm()
+    {
+        return $this->_lastTerm;
+    }
+
+
+    /**
+     * Returns an array of all term positions in the documents.
+     * Return array structure: array( docId => array( pos1, pos2, ...), ...)
+     *
+     * @return array
+     */
+    public function currentTermPositions()
+    {
+        return $this->_lastTermPositions;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/SegmentMerger.php b/inc/lib/Zend/Search/Lucene/Index/SegmentMerger.php
new file mode 100644 (file)
index 0000000..5afdced
--- /dev/null
@@ -0,0 +1,271 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: SegmentMerger.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Index_SegmentInfo */
+require_once 'Zend/Search/Lucene/Index/SegmentInfo.php';
+
+/** Zend_Search_Lucene_Index_SegmentWriter_StreamWriter */
+require_once 'Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php';
+
+/** Zend_Search_Lucene_Index_TermsPriorityQueue */
+require_once 'Zend/Search/Lucene/Index/TermsPriorityQueue.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_SegmentMerger
+{
+    /**
+     * Target segment writer
+     *
+     * @var Zend_Search_Lucene_Index_SegmentWriter_StreamWriter
+     */
+    private $_writer;
+
+    /**
+     * Number of docs in a new segment
+     *
+     * @var integer
+     */
+    private $_docCount;
+
+    /**
+     * A set of segments to be merged
+     *
+     * @var array Zend_Search_Lucene_Index_SegmentInfo
+     */
+    private $_segmentInfos = array();
+
+    /**
+     * Flag to signal, that merge is already done
+     *
+     * @var boolean
+     */
+    private $_mergeDone = false;
+
+    /**
+     * Field map
+     * [<segment_name>][<field_number>] => <target_field_number>
+     *
+     * @var array
+     */
+    private $_fieldsMap = array();
+
+
+
+    /**
+     * Object constructor.
+     *
+     * Creates new segment merger with $directory as target to merge segments into
+     * and $name as a name of new segment
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @param string $name
+     */
+    public function __construct($directory, $name)
+    {
+        $this->_writer = new Zend_Search_Lucene_Index_SegmentWriter_StreamWriter($directory, $name);
+    }
+
+
+    /**
+     * Add segmnet to a collection of segments to be merged
+     *
+     * @param Zend_Search_Lucene_Index_SegmentInfo $segment
+     */
+    public function addSource(Zend_Search_Lucene_Index_SegmentInfo $segmentInfo)
+    {
+        $this->_segmentInfos[$segmentInfo->getName()] = $segmentInfo;
+    }
+
+
+    /**
+     * Do merge.
+     *
+     * Returns number of documents in newly created segment
+     *
+     * @return Zend_Search_Lucene_Index_SegmentInfo
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function merge()
+    {
+        if ($this->_mergeDone) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Merge is already done.');
+        }
+
+        if (count($this->_segmentInfos) < 1) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Wrong number of segments to be merged ('
+                                                 . count($this->_segmentInfos)
+                                                 . ').');
+        }
+
+        $this->_mergeFields();
+        $this->_mergeNorms();
+        $this->_mergeStoredFields();
+        $this->_mergeTerms();
+
+        $this->_mergeDone = true;
+
+        return $this->_writer->close();
+    }
+
+
+    /**
+     * Merge fields information
+     */
+    private function _mergeFields()
+    {
+        foreach ($this->_segmentInfos as $segName => $segmentInfo) {
+            foreach ($segmentInfo->getFieldInfos() as $fieldInfo) {
+                $this->_fieldsMap[$segName][$fieldInfo->number] = $this->_writer->addFieldInfo($fieldInfo);
+            }
+        }
+    }
+
+    /**
+     * Merge field's normalization factors
+     */
+    private function _mergeNorms()
+    {
+        foreach ($this->_writer->getFieldInfos() as $fieldInfo) {
+            if ($fieldInfo->isIndexed) {
+                foreach ($this->_segmentInfos as $segName => $segmentInfo) {
+                    if ($segmentInfo->hasDeletions()) {
+                        $srcNorm = $segmentInfo->normVector($fieldInfo->name);
+                        $norm    = '';
+                        $docs    = $segmentInfo->count();
+                        for ($count = 0; $count < $docs; $count++) {
+                            if (!$segmentInfo->isDeleted($count)) {
+                                $norm .= $srcNorm[$count];
+                            }
+                        }
+                        $this->_writer->addNorm($fieldInfo->name, $norm);
+                    } else {
+                        $this->_writer->addNorm($fieldInfo->name, $segmentInfo->normVector($fieldInfo->name));
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Merge fields information
+     */
+    private function _mergeStoredFields()
+    {
+        $this->_docCount = 0;
+
+        foreach ($this->_segmentInfos as $segName => $segmentInfo) {
+            $fdtFile = $segmentInfo->openCompoundFile('.fdt');
+
+            for ($count = 0; $count < $segmentInfo->count(); $count++) {
+                $fieldCount = $fdtFile->readVInt();
+                $storedFields = array();
+
+                for ($count2 = 0; $count2 < $fieldCount; $count2++) {
+                    $fieldNum = $fdtFile->readVInt();
+                    $bits = $fdtFile->readByte();
+                    $fieldInfo = $segmentInfo->getField($fieldNum);
+
+                    if (!($bits & 2)) { // Text data
+                        $storedFields[] =
+                                 new Zend_Search_Lucene_Field($fieldInfo->name,
+                                                              $fdtFile->readString(),
+                                                              'UTF-8',
+                                                              true,
+                                                              $fieldInfo->isIndexed,
+                                                              $bits & 1 );
+                    } else {            // Binary data
+                        $storedFields[] =
+                                 new Zend_Search_Lucene_Field($fieldInfo->name,
+                                                              $fdtFile->readBinary(),
+                                                              '',
+                                                              true,
+                                                              $fieldInfo->isIndexed,
+                                                              $bits & 1,
+                                                              true);
+                    }
+                }
+
+                if (!$segmentInfo->isDeleted($count)) {
+                    $this->_docCount++;
+                    $this->_writer->addStoredFields($storedFields);
+                }
+            }
+        }
+    }
+
+
+    /**
+     * Merge fields information
+     */
+    private function _mergeTerms()
+    {
+        $segmentInfoQueue = new Zend_Search_Lucene_Index_TermsPriorityQueue();
+
+        $segmentStartId = 0;
+        foreach ($this->_segmentInfos as $segName => $segmentInfo) {
+            $segmentStartId = $segmentInfo->resetTermsStream($segmentStartId, Zend_Search_Lucene_Index_SegmentInfo::SM_MERGE_INFO);
+
+            // Skip "empty" segments
+            if ($segmentInfo->currentTerm() !== null) {
+                $segmentInfoQueue->put($segmentInfo);
+            }
+        }
+
+        $this->_writer->initializeDictionaryFiles();
+
+        $termDocs = array();
+        while (($segmentInfo = $segmentInfoQueue->pop()) !== null) {
+            // Merge positions array
+            $termDocs += $segmentInfo->currentTermPositions();
+
+            if ($segmentInfoQueue->top() === null ||
+                $segmentInfoQueue->top()->currentTerm()->key() !=
+                            $segmentInfo->currentTerm()->key()) {
+                // We got new term
+                ksort($termDocs, SORT_NUMERIC);
+
+                // Add term if it's contained in any document
+                if (count($termDocs) > 0) {
+                    $this->_writer->addTerm($segmentInfo->currentTerm(), $termDocs);
+                }
+                $termDocs = array();
+            }
+
+            $segmentInfo->nextTerm();
+            // check, if segment dictionary is finished
+            if ($segmentInfo->currentTerm() !== null) {
+                // Put segment back into the priority queue
+                $segmentInfoQueue->put($segmentInfo);
+            }
+        }
+
+        $this->_writer->closeDictionaryFiles();
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Index/SegmentWriter.php b/inc/lib/Zend/Search/Lucene/Index/SegmentWriter.php
new file mode 100644 (file)
index 0000000..63cd4ea
--- /dev/null
@@ -0,0 +1,627 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: SegmentWriter.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Index_SegmentInfo */
+require_once 'Zend/Search/Lucene/Index/SegmentInfo.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Index_SegmentWriter
+{
+    /**
+     * Expert: The fraction of terms in the "dictionary" which should be stored
+     * in RAM.  Smaller values use more memory, but make searching slightly
+     * faster, while larger values use less memory and make searching slightly
+     * slower.  Searching is typically not dominated by dictionary lookup, so
+     * tweaking this is rarely useful.
+     *
+     * @var integer
+     */
+    public static $indexInterval = 128;
+
+    /**
+     * Expert: The fraction of TermDocs entries stored in skip tables.
+     * Larger values result in smaller indexes, greater acceleration, but fewer
+     * accelerable cases, while smaller values result in bigger indexes,
+     * less acceleration and more
+     * accelerable cases. More detailed experiments would be useful here.
+     *
+     * 0x7FFFFFFF indicates that we don't use skip data
+     *
+     * Note: not used in current implementation
+     *
+     * @var integer
+     */
+    public static $skipInterval = 0x7FFFFFFF;
+
+    /**
+     * Expert: The maximum number of skip levels. Smaller values result in
+     * slightly smaller indexes, but slower skipping in big posting lists.
+     *
+     * 0 indicates that we don't use skip data
+     *
+     * Note: not used in current implementation
+     *
+     * @var integer
+     */
+    public static $maxSkipLevels = 0;
+
+    /**
+     * Number of docs in a segment
+     *
+     * @var integer
+     */
+    protected $_docCount = 0;
+
+    /**
+     * Segment name
+     *
+     * @var string
+     */
+    protected $_name;
+
+    /**
+     * File system adapter.
+     *
+     * @var Zend_Search_Lucene_Storage_Directory
+     */
+    protected $_directory;
+
+    /**
+     * List of the index files.
+     * Used for automatic compound file generation
+     *
+     * @var unknown_type
+     */
+    protected $_files = array();
+
+    /**
+     * Segment fields. Array of Zend_Search_Lucene_Index_FieldInfo objects for this segment
+     *
+     * @var array
+     */
+    protected $_fields = array();
+
+    /**
+     * Normalization factors.
+     * An array fieldName => normVector
+     * normVector is a binary string.
+     * Each byte corresponds to an indexed document in a segment and
+     * encodes normalization factor (float value, encoded by
+     * Zend_Search_Lucene_Search_Similarity::encodeNorm())
+     *
+     * @var array
+     */
+    protected $_norms = array();
+
+
+    /**
+     * '.fdx'  file - Stored Fields, the field index.
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    protected $_fdxFile = null;
+
+    /**
+     * '.fdt'  file - Stored Fields, the field data.
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    protected $_fdtFile = null;
+
+
+    /**
+     * Object constructor.
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @param string $name
+     */
+    public function __construct(Zend_Search_Lucene_Storage_Directory $directory, $name)
+    {
+        $this->_directory = $directory;
+        $this->_name      = $name;
+    }
+
+
+    /**
+     * Add field to the segment
+     *
+     * Returns actual field number
+     *
+     * @param Zend_Search_Lucene_Field $field
+     * @return integer
+     */
+    public function addField(Zend_Search_Lucene_Field $field)
+    {
+        if (!isset($this->_fields[$field->name])) {
+            $fieldNumber = count($this->_fields);
+            $this->_fields[$field->name] =
+                                new Zend_Search_Lucene_Index_FieldInfo($field->name,
+                                                                       $field->isIndexed,
+                                                                       $fieldNumber,
+                                                                       $field->storeTermVector);
+
+            return $fieldNumber;
+        } else {
+            $this->_fields[$field->name]->isIndexed       |= $field->isIndexed;
+            $this->_fields[$field->name]->storeTermVector |= $field->storeTermVector;
+
+            return $this->_fields[$field->name]->number;
+        }
+    }
+
+    /**
+     * Add fieldInfo to the segment
+     *
+     * Returns actual field number
+     *
+     * @param Zend_Search_Lucene_Index_FieldInfo $fieldInfo
+     * @return integer
+     */
+    public function addFieldInfo(Zend_Search_Lucene_Index_FieldInfo $fieldInfo)
+    {
+        if (!isset($this->_fields[$fieldInfo->name])) {
+            $fieldNumber = count($this->_fields);
+            $this->_fields[$fieldInfo->name] =
+                                new Zend_Search_Lucene_Index_FieldInfo($fieldInfo->name,
+                                                                       $fieldInfo->isIndexed,
+                                                                       $fieldNumber,
+                                                                       $fieldInfo->storeTermVector);
+
+            return $fieldNumber;
+        } else {
+            $this->_fields[$fieldInfo->name]->isIndexed       |= $fieldInfo->isIndexed;
+            $this->_fields[$fieldInfo->name]->storeTermVector |= $fieldInfo->storeTermVector;
+
+            return $this->_fields[$fieldInfo->name]->number;
+        }
+    }
+
+    /**
+     * Returns array of FieldInfo objects.
+     *
+     * @return array
+     */
+    public function getFieldInfos()
+    {
+        return $this->_fields;
+    }
+
+    /**
+     * Add stored fields information
+     *
+     * @param array $storedFields array of Zend_Search_Lucene_Field objects
+     */
+    public function addStoredFields($storedFields)
+    {
+        if (!isset($this->_fdxFile)) {
+            $this->_fdxFile = $this->_directory->createFile($this->_name . '.fdx');
+            $this->_fdtFile = $this->_directory->createFile($this->_name . '.fdt');
+
+            $this->_files[] = $this->_name . '.fdx';
+            $this->_files[] = $this->_name . '.fdt';
+        }
+
+        $this->_fdxFile->writeLong($this->_fdtFile->tell());
+        $this->_fdtFile->writeVInt(count($storedFields));
+        foreach ($storedFields as $field) {
+            $this->_fdtFile->writeVInt($this->_fields[$field->name]->number);
+            $fieldBits = ($field->isTokenized ? 0x01 : 0x00) |
+                         ($field->isBinary ?    0x02 : 0x00) |
+                         0x00; /* 0x04 - third bit, compressed (ZLIB) */
+            $this->_fdtFile->writeByte($fieldBits);
+            if ($field->isBinary) {
+                $this->_fdtFile->writeVInt(strlen($field->value));
+                $this->_fdtFile->writeBytes($field->value);
+            } else {
+                $this->_fdtFile->writeString($field->getUtf8Value());
+            }
+        }
+
+        $this->_docCount++;
+    }
+
+    /**
+     * Returns the total number of documents in this segment.
+     *
+     * @return integer
+     */
+    public function count()
+    {
+        return $this->_docCount;
+    }
+
+    /**
+     * Return segment name
+     *
+     * @return string
+     */
+    public function getName()
+    {
+        return $this->_name;
+    }
+
+    /**
+     * Dump Field Info (.fnm) segment file
+     */
+    protected function _dumpFNM()
+    {
+        $fnmFile = $this->_directory->createFile($this->_name . '.fnm');
+        $fnmFile->writeVInt(count($this->_fields));
+
+        $nrmFile = $this->_directory->createFile($this->_name . '.nrm');
+        // Write header
+        $nrmFile->writeBytes('NRM');
+        // Write format specifier
+        $nrmFile->writeByte((int)0xFF);
+
+        foreach ($this->_fields as $field) {
+            $fnmFile->writeString($field->name);
+            $fnmFile->writeByte(($field->isIndexed       ? 0x01 : 0x00) |
+                                ($field->storeTermVector ? 0x02 : 0x00)
+// not supported yet            0x04 /* term positions are stored with the term vectors */ |
+// not supported yet            0x08 /* term offsets are stored with the term vectors */   |
+                               );
+
+            if ($field->isIndexed) {
+                // pre-2.1 index mode (not used now)
+                // $normFileName = $this->_name . '.f' . $field->number;
+                // $fFile = $this->_directory->createFile($normFileName);
+                // $fFile->writeBytes($this->_norms[$field->name]);
+                // $this->_files[] = $normFileName;
+
+                $nrmFile->writeBytes($this->_norms[$field->name]);
+            }
+        }
+
+        $this->_files[] = $this->_name . '.fnm';
+        $this->_files[] = $this->_name . '.nrm';
+    }
+
+
+
+    /**
+     * Term Dictionary file
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    private $_tisFile = null;
+
+    /**
+     * Term Dictionary index file
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    private $_tiiFile = null;
+
+    /**
+     * Frequencies file
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    private $_frqFile = null;
+
+    /**
+     * Positions file
+     *
+     * @var Zend_Search_Lucene_Storage_File
+     */
+    private $_prxFile = null;
+
+    /**
+     * Number of written terms
+     *
+     * @var integer
+     */
+    private $_termCount;
+
+
+    /**
+     * Last saved term
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_prevTerm;
+
+    /**
+     * Last saved term info
+     *
+     * @var Zend_Search_Lucene_Index_TermInfo
+     */
+    private $_prevTermInfo;
+
+    /**
+     * Last saved index term
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_prevIndexTerm;
+
+    /**
+     * Last saved index term info
+     *
+     * @var Zend_Search_Lucene_Index_TermInfo
+     */
+    private $_prevIndexTermInfo;
+
+    /**
+     * Last term dictionary file position
+     *
+     * @var integer
+     */
+    private $_lastIndexPosition;
+
+    /**
+     * Create dicrionary, frequency and positions files and write necessary headers
+     */
+    public function initializeDictionaryFiles()
+    {
+        $this->_tisFile = $this->_directory->createFile($this->_name . '.tis');
+        $this->_tisFile->writeInt((int)0xFFFFFFFD);
+        $this->_tisFile->writeLong(0 /* dummy data for terms count */);
+        $this->_tisFile->writeInt(self::$indexInterval);
+        $this->_tisFile->writeInt(self::$skipInterval);
+        $this->_tisFile->writeInt(self::$maxSkipLevels);
+
+        $this->_tiiFile = $this->_directory->createFile($this->_name . '.tii');
+        $this->_tiiFile->writeInt((int)0xFFFFFFFD);
+        $this->_tiiFile->writeLong(0 /* dummy data for terms count */);
+        $this->_tiiFile->writeInt(self::$indexInterval);
+        $this->_tiiFile->writeInt(self::$skipInterval);
+        $this->_tiiFile->writeInt(self::$maxSkipLevels);
+
+        /** Dump dictionary header */
+        $this->_tiiFile->writeVInt(0);                    // preffix length
+        $this->_tiiFile->writeString('');                 // suffix
+        $this->_tiiFile->writeInt((int)0xFFFFFFFF);       // field number
+        $this->_tiiFile->writeByte((int)0x0F);
+        $this->_tiiFile->writeVInt(0);                    // DocFreq
+        $this->_tiiFile->writeVInt(0);                    // FreqDelta
+        $this->_tiiFile->writeVInt(0);                    // ProxDelta
+        $this->_tiiFile->writeVInt(24);                   // IndexDelta
+
+        $this->_frqFile = $this->_directory->createFile($this->_name . '.frq');
+        $this->_prxFile = $this->_directory->createFile($this->_name . '.prx');
+
+        $this->_files[] = $this->_name . '.tis';
+        $this->_files[] = $this->_name . '.tii';
+        $this->_files[] = $this->_name . '.frq';
+        $this->_files[] = $this->_name . '.prx';
+
+        $this->_prevTerm          = null;
+        $this->_prevTermInfo      = null;
+        $this->_prevIndexTerm     = null;
+        $this->_prevIndexTermInfo = null;
+        $this->_lastIndexPosition = 24;
+        $this->_termCount         = 0;
+
+    }
+
+    /**
+     * Add term
+     *
+     * Term positions is an array( docId => array(pos1, pos2, pos3, ...), ... )
+     *
+     * @param Zend_Search_Lucene_Index_Term $termEntry
+     * @param array $termDocs
+     */
+    public function addTerm($termEntry, $termDocs)
+    {
+        $freqPointer = $this->_frqFile->tell();
+        $proxPointer = $this->_prxFile->tell();
+
+        $prevDoc = 0;
+        foreach ($termDocs as $docId => $termPositions) {
+            $docDelta = ($docId - $prevDoc)*2;
+            $prevDoc = $docId;
+            if (count($termPositions) > 1) {
+                $this->_frqFile->writeVInt($docDelta);
+                $this->_frqFile->writeVInt(count($termPositions));
+            } else {
+                $this->_frqFile->writeVInt($docDelta + 1);
+            }
+
+            $prevPosition = 0;
+            foreach ($termPositions as $position) {
+                $this->_prxFile->writeVInt($position - $prevPosition);
+                $prevPosition = $position;
+            }
+        }
+
+        if (count($termDocs) >= self::$skipInterval) {
+            /**
+             * @todo Write Skip Data to a freq file.
+             * It's not used now, but make index more optimal
+             */
+            $skipOffset = $this->_frqFile->tell() - $freqPointer;
+        } else {
+            $skipOffset = 0;
+        }
+
+        $term = new Zend_Search_Lucene_Index_Term($termEntry->text,
+                                                  $this->_fields[$termEntry->field]->number);
+        $termInfo = new Zend_Search_Lucene_Index_TermInfo(count($termDocs),
+                                                          $freqPointer, $proxPointer, $skipOffset);
+
+        $this->_dumpTermDictEntry($this->_tisFile, $this->_prevTerm, $term, $this->_prevTermInfo, $termInfo);
+
+        if (($this->_termCount + 1) % self::$indexInterval == 0) {
+            $this->_dumpTermDictEntry($this->_tiiFile, $this->_prevIndexTerm, $term, $this->_prevIndexTermInfo, $termInfo);
+
+            $indexPosition = $this->_tisFile->tell();
+            $this->_tiiFile->writeVInt($indexPosition - $this->_lastIndexPosition);
+            $this->_lastIndexPosition = $indexPosition;
+
+        }
+        $this->_termCount++;
+    }
+
+    /**
+     * Close dictionary
+     */
+    public function closeDictionaryFiles()
+    {
+        $this->_tisFile->seek(4);
+        $this->_tisFile->writeLong($this->_termCount);
+
+        $this->_tiiFile->seek(4);
+        // + 1 is used to count an additional special index entry (empty term at the start of the list)
+        $this->_tiiFile->writeLong(($this->_termCount - $this->_termCount % self::$indexInterval)/self::$indexInterval + 1);
+    }
+
+
+    /**
+     * Dump Term Dictionary segment file entry.
+     * Used to write entry to .tis or .tii files
+     *
+     * @param Zend_Search_Lucene_Storage_File $dicFile
+     * @param Zend_Search_Lucene_Index_Term $prevTerm
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_TermInfo $prevTermInfo
+     * @param Zend_Search_Lucene_Index_TermInfo $termInfo
+     */
+    protected function _dumpTermDictEntry(Zend_Search_Lucene_Storage_File $dicFile,
+                                        &$prevTerm,     Zend_Search_Lucene_Index_Term     $term,
+                                        &$prevTermInfo, Zend_Search_Lucene_Index_TermInfo $termInfo)
+    {
+        if (isset($prevTerm) && $prevTerm->field == $term->field) {
+            $matchedBytes = 0;
+            $maxBytes = min(strlen($prevTerm->text), strlen($term->text));
+            while ($matchedBytes < $maxBytes  &&
+                   $prevTerm->text[$matchedBytes] == $term->text[$matchedBytes]) {
+                $matchedBytes++;
+            }
+
+            // Calculate actual matched UTF-8 pattern
+            $prefixBytes = 0;
+            $prefixChars = 0;
+            while ($prefixBytes < $matchedBytes) {
+                $charBytes = 1;
+                if ((ord($term->text[$prefixBytes]) & 0xC0) == 0xC0) {
+                    $charBytes++;
+                    if (ord($term->text[$prefixBytes]) & 0x20 ) {
+                        $charBytes++;
+                        if (ord($term->text[$prefixBytes]) & 0x10 ) {
+                            $charBytes++;
+                        }
+                    }
+                }
+
+                if ($prefixBytes + $charBytes > $matchedBytes) {
+                    // char crosses matched bytes boundary
+                    // skip char
+                    break;
+                }
+
+                $prefixChars++;
+                $prefixBytes += $charBytes;
+            }
+
+            // Write preffix length
+            $dicFile->writeVInt($prefixChars);
+            // Write suffix
+            $dicFile->writeString(substr($term->text, $prefixBytes));
+        } else {
+            // Write preffix length
+            $dicFile->writeVInt(0);
+            // Write suffix
+            $dicFile->writeString($term->text);
+        }
+        // Write field number
+        $dicFile->writeVInt($term->field);
+        // DocFreq (the count of documents which contain the term)
+        $dicFile->writeVInt($termInfo->docFreq);
+
+        $prevTerm = $term;
+
+        if (!isset($prevTermInfo)) {
+            // Write FreqDelta
+            $dicFile->writeVInt($termInfo->freqPointer);
+            // Write ProxDelta
+            $dicFile->writeVInt($termInfo->proxPointer);
+        } else {
+            // Write FreqDelta
+            $dicFile->writeVInt($termInfo->freqPointer - $prevTermInfo->freqPointer);
+            // Write ProxDelta
+            $dicFile->writeVInt($termInfo->proxPointer - $prevTermInfo->proxPointer);
+        }
+        // Write SkipOffset - it's not 0 when $termInfo->docFreq > self::$skipInterval
+        if ($termInfo->skipOffset != 0) {
+            $dicFile->writeVInt($termInfo->skipOffset);
+        }
+
+        $prevTermInfo = $termInfo;
+    }
+
+
+    /**
+     * Generate compound index file
+     */
+    protected function _generateCFS()
+    {
+        $cfsFile = $this->_directory->createFile($this->_name . '.cfs');
+        $cfsFile->writeVInt(count($this->_files));
+
+        $dataOffsetPointers = array();
+        foreach ($this->_files as $fileName) {
+            $dataOffsetPointers[$fileName] = $cfsFile->tell();
+            $cfsFile->writeLong(0); // write dummy data
+            $cfsFile->writeString($fileName);
+        }
+
+        foreach ($this->_files as $fileName) {
+            // Get actual data offset
+            $dataOffset = $cfsFile->tell();
+            // Seek to the data offset pointer
+            $cfsFile->seek($dataOffsetPointers[$fileName]);
+            // Write actual data offset value
+            $cfsFile->writeLong($dataOffset);
+            // Seek back to the end of file
+            $cfsFile->seek($dataOffset);
+
+            $dataFile = $this->_directory->getFileObject($fileName);
+
+            $byteCount = $this->_directory->fileLength($fileName);
+            while ($byteCount > 0) {
+                $data = $dataFile->readBytes(min($byteCount, 131072 /*128Kb*/));
+                $byteCount -= strlen($data);
+                $cfsFile->writeBytes($data);
+            }
+
+            $this->_directory->deleteFile($fileName);
+        }
+    }
+
+
+    /**
+     * Close segment, write it to disk and return segment info
+     *
+     * @return Zend_Search_Lucene_Index_SegmentInfo
+     */
+    abstract public function close();
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php b/inc/lib/Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php
new file mode 100644 (file)
index 0000000..1e9f885
--- /dev/null
@@ -0,0 +1,214 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: DocumentWriter.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Analysis_Analyzer */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer.php';
+
+/** Zend_Search_Lucene_Index_SegmentWriter */
+require_once 'Zend/Search/Lucene/Index/SegmentWriter.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_SegmentWriter_DocumentWriter extends Zend_Search_Lucene_Index_SegmentWriter
+{
+    /**
+     * Term Dictionary
+     * Array of the Zend_Search_Lucene_Index_Term objects
+     * Corresponding Zend_Search_Lucene_Index_TermInfo object stored in the $_termDictionaryInfos
+     *
+     * @var array
+     */
+    protected $_termDictionary;
+
+    /**
+     * Documents, which contain the term
+     *
+     * @var array
+     */
+    protected $_termDocs;
+
+    /**
+     * Object constructor.
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @param string $name
+     */
+    public function __construct(Zend_Search_Lucene_Storage_Directory $directory, $name)
+    {
+        parent::__construct($directory, $name);
+
+        $this->_termDocs       = array();
+        $this->_termDictionary = array();
+    }
+
+
+    /**
+     * Adds a document to this segment.
+     *
+     * @param Zend_Search_Lucene_Document $document
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function addDocument(Zend_Search_Lucene_Document $document)
+    {
+        $storedFields = array();
+        $docNorms     = array();
+        $similarity   = Zend_Search_Lucene_Search_Similarity::getDefault();
+
+        foreach ($document->getFieldNames() as $fieldName) {
+            $field = $document->getField($fieldName);
+            $this->addField($field);
+
+            if ($field->storeTermVector) {
+                /**
+                 * @todo term vector storing support
+                 */
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Store term vector functionality is not supported yet.');
+            }
+
+            if ($field->isIndexed) {
+                if ($field->isTokenized) {
+                    $analyzer = Zend_Search_Lucene_Analysis_Analyzer::getDefault();
+                    $analyzer->setInput($field->value, $field->encoding);
+
+                    $position     = 0;
+                    $tokenCounter = 0;
+                    while (($token = $analyzer->nextToken()) !== null) {
+                        $tokenCounter++;
+
+                        $term = new Zend_Search_Lucene_Index_Term($token->getTermText(), $field->name);
+                        $termKey = $term->key();
+
+                        if (!isset($this->_termDictionary[$termKey])) {
+                            // New term
+                            $this->_termDictionary[$termKey] = $term;
+                            $this->_termDocs[$termKey] = array();
+                            $this->_termDocs[$termKey][$this->_docCount] = array();
+                        } else if (!isset($this->_termDocs[$termKey][$this->_docCount])) {
+                            // Existing term, but new term entry
+                            $this->_termDocs[$termKey][$this->_docCount] = array();
+                        }
+                        $position += $token->getPositionIncrement();
+                        $this->_termDocs[$termKey][$this->_docCount][] = $position;
+                    }
+
+                    $docNorms[$field->name] = chr($similarity->encodeNorm( $similarity->lengthNorm($field->name,
+                                                                                                   $tokenCounter)*
+                                                                           $document->boost*
+                                                                           $field->boost ));
+                } else {
+                    $term = new Zend_Search_Lucene_Index_Term($field->getUtf8Value(), $field->name);
+                    $termKey = $term->key();
+
+                    if (!isset($this->_termDictionary[$termKey])) {
+                        // New term
+                        $this->_termDictionary[$termKey] = $term;
+                        $this->_termDocs[$termKey] = array();
+                        $this->_termDocs[$termKey][$this->_docCount] = array();
+                    } else if (!isset($this->_termDocs[$termKey][$this->_docCount])) {
+                        // Existing term, but new term entry
+                        $this->_termDocs[$termKey][$this->_docCount] = array();
+                    }
+                    $this->_termDocs[$termKey][$this->_docCount][] = 0; // position
+
+                    $docNorms[$field->name] = chr($similarity->encodeNorm( $similarity->lengthNorm($field->name, 1)*
+                                                                           $document->boost*
+                                                                           $field->boost ));
+                }
+            }
+
+            if ($field->isStored) {
+                $storedFields[] = $field;
+            }
+        }
+
+
+        foreach ($this->_fields as $fieldName => $field) {
+            if (!$field->isIndexed) {
+                continue;
+            }
+
+            if (!isset($this->_norms[$fieldName])) {
+                $this->_norms[$fieldName] = str_repeat(chr($similarity->encodeNorm( $similarity->lengthNorm($fieldName, 0) )),
+                                                       $this->_docCount);
+            }
+
+            if (isset($docNorms[$fieldName])){
+                $this->_norms[$fieldName] .= $docNorms[$fieldName];
+            } else {
+                $this->_norms[$fieldName] .= chr($similarity->encodeNorm( $similarity->lengthNorm($fieldName, 0) ));
+            }
+        }
+
+        $this->addStoredFields($storedFields);
+    }
+
+
+    /**
+     * Dump Term Dictionary (.tis) and Term Dictionary Index (.tii) segment files
+     */
+    protected function _dumpDictionary()
+    {
+        ksort($this->_termDictionary, SORT_STRING);
+
+        $this->initializeDictionaryFiles();
+
+        foreach ($this->_termDictionary as $termId => $term) {
+            $this->addTerm($term, $this->_termDocs[$termId]);
+        }
+
+        $this->closeDictionaryFiles();
+    }
+
+
+    /**
+     * Close segment, write it to disk and return segment info
+     *
+     * @return Zend_Search_Lucene_Index_SegmentInfo
+     */
+    public function close()
+    {
+        if ($this->_docCount == 0) {
+            return null;
+        }
+
+        $this->_dumpFNM();
+        $this->_dumpDictionary();
+
+        $this->_generateCFS();
+
+        return new Zend_Search_Lucene_Index_SegmentInfo($this->_directory,
+                                                        $this->_name,
+                                                        $this->_docCount,
+                                                        -1,
+                                                        null,
+                                                        true,
+                                                        true);
+    }
+
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php b/inc/lib/Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php
new file mode 100644 (file)
index 0000000..b6b2ca8
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: StreamWriter.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Index_SegmentInfo */
+require_once 'Zend/Search/Lucene/Index/SegmentInfo.php';
+
+/** Zend_Search_Lucene_Index_SegmentWriter */
+require_once 'Zend/Search/Lucene/Index/SegmentWriter.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_SegmentWriter_StreamWriter extends Zend_Search_Lucene_Index_SegmentWriter
+{
+    /**
+     * Object constructor.
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @param string $name
+     */
+    public function __construct(Zend_Search_Lucene_Storage_Directory $directory, $name)
+    {
+        parent::__construct($directory, $name);
+    }
+
+
+    /**
+     * Create stored fields files and open them for write
+     */
+    public function createStoredFieldsFiles()
+    {
+        $this->_fdxFile = $this->_directory->createFile($this->_name . '.fdx');
+        $this->_fdtFile = $this->_directory->createFile($this->_name . '.fdt');
+
+        $this->_files[] = $this->_name . '.fdx';
+        $this->_files[] = $this->_name . '.fdt';
+    }
+
+    public function addNorm($fieldName, $normVector)
+    {
+        if (isset($this->_norms[$fieldName])) {
+            $this->_norms[$fieldName] .= $normVector;
+        } else {
+            $this->_norms[$fieldName] = $normVector;
+        }
+    }
+
+    /**
+     * Close segment, write it to disk and return segment info
+     *
+     * @return Zend_Search_Lucene_Index_SegmentInfo
+     */
+    public function close()
+    {
+        if ($this->_docCount == 0) {
+            return null;
+        }
+
+        $this->_dumpFNM();
+        $this->_generateCFS();
+
+        return new Zend_Search_Lucene_Index_SegmentInfo($this->_directory,
+                                                        $this->_name,
+                                                        $this->_docCount,
+                                                        -1,
+                                                        null,
+                                                        true,
+                                                        true);
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/Term.php b/inc/lib/Zend/Search/Lucene/Index/Term.php
new file mode 100644 (file)
index 0000000..a042cfd
--- /dev/null
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Term.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * A Term represents a word from text.  This is the unit of search.  It is
+ * composed of two elements, the text of the word, as a string, and the name of
+ * the field that the text occured in, an interned string.
+ *
+ * Note that terms may represent more than words from text fields, but also
+ * things like dates, email addresses, urls, etc.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_Term
+{
+    /**
+     * Field name or field number (depending from context)
+     *
+     * @var mixed
+     */
+    public $field;
+
+    /**
+     * Term value
+     *
+     * @var string
+     */
+    public $text;
+
+
+    /**
+     * Object constructor
+     */
+    public function __construct($text, $field = null)
+    {
+        $this->field = ($field === null)?  Zend_Search_Lucene::getDefaultSearchField() : $field;
+        $this->text  = $text;
+    }
+
+
+    /**
+     * Returns term key
+     *
+     * @return string
+     */
+    public function key()
+    {
+        return $this->field . chr(0) . $this->text;
+    }
+
+    /**
+     * Get term prefix
+     *
+     * @param string $str
+     * @param integer $length
+     * @return string
+     */
+    public static function getPrefix($str, $length)
+    {
+        $prefixBytes = 0;
+        $prefixChars = 0;
+        while ($prefixBytes < strlen($str)  &&  $prefixChars < $length) {
+            $charBytes = 1;
+            if ((ord($str[$prefixBytes]) & 0xC0) == 0xC0) {
+                $charBytes++;
+                if (ord($str[$prefixBytes]) & 0x20 ) {
+                    $charBytes++;
+                    if (ord($str[$prefixBytes]) & 0x10 ) {
+                        $charBytes++;
+                    }
+                }
+            }
+
+            if ($prefixBytes + $charBytes > strlen($str)) {
+                // wrong character
+                break;
+            }
+
+            $prefixChars++;
+            $prefixBytes += $charBytes;
+        }
+
+        return substr($str, 0, $prefixBytes);
+    }
+
+    /**
+     * Get UTF-8 string length
+     *
+     * @param string $str
+     * @return string
+     */
+    public static function getLength($str)
+    {
+        $bytes = 0;
+        $chars = 0;
+        while ($bytes < strlen($str)) {
+            $charBytes = 1;
+            if ((ord($str[$bytes]) & 0xC0) == 0xC0) {
+                $charBytes++;
+                if (ord($str[$bytes]) & 0x20 ) {
+                    $charBytes++;
+                    if (ord($str[$bytes]) & 0x10 ) {
+                        $charBytes++;
+                    }
+                }
+            }
+
+            if ($bytes + $charBytes > strlen($str)) {
+                // wrong character
+                break;
+            }
+
+            $chars++;
+            $bytes += $charBytes;
+        }
+
+        return $chars;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/TermInfo.php b/inc/lib/Zend/Search/Lucene/Index/TermInfo.php
new file mode 100644 (file)
index 0000000..2cd822d
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: TermInfo.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * A Zend_Search_Lucene_Index_TermInfo represents a record of information stored for a term.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_TermInfo
+{
+    /**
+     * The number of documents which contain the term.
+     *
+     * @var integer
+     */
+    public $docFreq;
+
+    /**
+     * Data offset in a Frequencies file.
+     *
+     * @var integer
+     */
+    public $freqPointer;
+
+    /**
+     * Data offset in a Positions file.
+     *
+     * @var integer
+     */
+    public $proxPointer;
+
+    /**
+     * ScipData offset in a Frequencies file.
+     *
+     * @var integer
+     */
+    public $skipOffset;
+
+    /**
+     * Term offset of the _next_ term in a TermDictionary file.
+     * Used only for Term Index
+     *
+     * @var integer
+     */
+    public $indexPointer;
+
+    public function __construct($docFreq, $freqPointer, $proxPointer, $skipOffset, $indexPointer = null)
+    {
+        $this->docFreq      = $docFreq;
+        $this->freqPointer  = $freqPointer;
+        $this->proxPointer  = $proxPointer;
+        $this->skipOffset   = $skipOffset;
+        $this->indexPointer = $indexPointer;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Index/TermsPriorityQueue.php b/inc/lib/Zend/Search/Lucene/Index/TermsPriorityQueue.php
new file mode 100644 (file)
index 0000000..cbe1021
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: TermsPriorityQueue.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene */
+require_once 'Zend/Search/Lucene/PriorityQueue.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_TermsPriorityQueue extends Zend_Search_Lucene_PriorityQueue
+{
+    /**
+     * Compare elements
+     *
+     * Returns true, if $termsStream1 is "less" than $termsStream2; else otherwise
+     *
+     * @param mixed $termsStream1
+     * @param mixed $termsStream2
+     * @return boolean
+     */
+    protected function _less($termsStream1, $termsStream2)
+    {
+        return strcmp($termsStream1->currentTerm()->key(), $termsStream2->currentTerm()->key()) < 0;
+    }
+
+}
diff --git a/inc/lib/Zend/Search/Lucene/Index/TermsStream/Interface.php b/inc/lib/Zend/Search/Lucene/Index/TermsStream/Interface.php
new file mode 100644 (file)
index 0000000..900b34e
--- /dev/null
@@ -0,0 +1,66 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Index\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: Interface.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+/**\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Index\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+interface Zend_Search_Lucene_Index_TermsStream_Interface\r
+{\r
+    /**\r
+     * Reset terms stream.\r
+     */\r
+       public function resetTermsStream();\r
+\r
+    /**\r
+     * Skip terms stream up to specified term preffix.\r
+     *\r
+     * Prefix contains fully specified field info and portion of searched term\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $prefix\r
+     */\r
+    public function skipTo(Zend_Search_Lucene_Index_Term $prefix);\r
+\r
+    /**\r
+     * Scans terms dictionary and returns next term\r
+     *\r
+     * @return Zend_Search_Lucene_Index_Term|null\r
+     */\r
+    public function nextTerm();\r
+\r
+    /**\r
+     * Returns term in current position\r
+     *\r
+     * @return Zend_Search_Lucene_Index_Term|null\r
+     */\r
+    public function currentTerm();\r
+\r
+    /**\r
+     * Close terms stream\r
+     *\r
+     * Should be used for resources clean up if stream is not read up to the end\r
+     */\r
+    public function closeTermsStream();\r
+}\r
diff --git a/inc/lib/Zend/Search/Lucene/Index/Writer.php b/inc/lib/Zend/Search/Lucene/Index/Writer.php
new file mode 100644 (file)
index 0000000..3f5bf0b
--- /dev/null
@@ -0,0 +1,842 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Writer.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Index_SegmentWriter_DocumentWriter */
+require_once 'Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php';
+
+/** Zend_Search_Lucene_Index_SegmentInfo */
+require_once 'Zend/Search/Lucene/Index/SegmentInfo.php';
+
+/** Zend_Search_Lucene_Index_SegmentMerger */
+require_once 'Zend/Search/Lucene/Index/SegmentMerger.php';
+
+/** Zend_Search_Lucene_LockManager */
+require_once 'Zend/Search/Lucene/LockManager.php';
+
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Index
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Index_Writer
+{
+    /**
+     * @todo Implement Analyzer substitution
+     * @todo Implement Zend_Search_Lucene_Storage_DirectoryRAM and Zend_Search_Lucene_Storage_FileRAM to use it for
+     *       temporary index files
+     * @todo Directory lock processing
+     */
+
+    /**
+     * Number of documents required before the buffered in-memory
+     * documents are written into a new Segment
+     *
+     * Default value is 10
+     *
+     * @var integer
+     */
+    public $maxBufferedDocs = 10;
+
+    /**
+     * Largest number of documents ever merged by addDocument().
+     * Small values (e.g., less than 10,000) are best for interactive indexing,
+     * as this limits the length of pauses while indexing to a few seconds.
+     * Larger values are best for batched indexing and speedier searches.
+     *
+     * Default value is PHP_INT_MAX
+     *
+     * @var integer
+     */
+    public $maxMergeDocs = PHP_INT_MAX;
+
+    /**
+     * Determines how often segment indices are merged by addDocument().
+     *
+     * With smaller values, less RAM is used while indexing,
+     * and searches on unoptimized indices are faster,
+     * but indexing speed is slower.
+     *
+     * With larger values, more RAM is used during indexing,
+     * and while searches on unoptimized indices are slower,
+     * indexing is faster.
+     *
+     * Thus larger values (> 10) are best for batch index creation,
+     * and smaller values (< 10) for indices that are interactively maintained.
+     *
+     * Default value is 10
+     *
+     * @var integer
+     */
+    public $mergeFactor = 10;
+
+    /**
+     * File system adapter.
+     *
+     * @var Zend_Search_Lucene_Storage_Directory
+     */
+    private $_directory = null;
+
+
+    /**
+     * Changes counter.
+     *
+     * @var integer
+     */
+    private $_versionUpdate = 0;
+
+    /**
+     * List of the segments, created by index writer
+     * Array of Zend_Search_Lucene_Index_SegmentInfo objects
+     *
+     * @var array
+     */
+    private $_newSegments = array();
+
+    /**
+     * List of segments to be deleted on commit
+     *
+     * @var array
+     */
+    private $_segmentsToDelete = array();
+
+    /**
+     * Current segment to add documents
+     *
+     * @var Zend_Search_Lucene_Index_SegmentWriter_DocumentWriter
+     */
+    private $_currentSegment = null;
+
+    /**
+     * Array of Zend_Search_Lucene_Index_SegmentInfo objects for this index.
+     *
+     * It's a reference to the corresponding Zend_Search_Lucene::$_segmentInfos array
+     *
+     * @var array Zend_Search_Lucene_Index_SegmentInfo
+     */
+    private $_segmentInfos;
+
+    /**
+     * Index target format version
+     *
+     * @var integer
+     */
+    private $_targetFormatVersion;
+
+    /**
+     * List of indexfiles extensions
+     *
+     * @var array
+     */
+    private static $_indexExtensions = array('.cfs' => '.cfs',
+                                             '.cfx' => '.cfx',
+                                             '.fnm' => '.fnm',
+                                             '.fdx' => '.fdx',
+                                             '.fdt' => '.fdt',
+                                             '.tis' => '.tis',
+                                             '.tii' => '.tii',
+                                             '.frq' => '.frq',
+                                             '.prx' => '.prx',
+                                             '.tvx' => '.tvx',
+                                             '.tvd' => '.tvd',
+                                             '.tvf' => '.tvf',
+                                             '.del' => '.del',
+                                             '.sti' => '.sti' );
+
+
+    /**
+     * Create empty index
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @param integer $generation
+     * @param integer $nameCount
+     */
+    public static function createIndex(Zend_Search_Lucene_Storage_Directory $directory, $generation, $nameCount)
+    {
+        if ($generation == 0) {
+            // Create index in pre-2.1 mode
+            foreach ($directory->fileList() as $file) {
+                if ($file == 'deletable' ||
+                    $file == 'segments'  ||
+                    isset(self::$_indexExtensions[ substr($file, strlen($file)-4)]) ||
+                    preg_match('/\.f\d+$/i', $file) /* matches <segment_name>.f<decimal_nmber> file names */) {
+                        $directory->deleteFile($file);
+                    }
+            }
+
+            $segmentsFile = $directory->createFile('segments');
+            $segmentsFile->writeInt((int)0xFFFFFFFF);
+
+            // write version (initialized by current time)
+            $segmentsFile->writeLong(round(microtime(true)));
+
+            // write name counter
+            $segmentsFile->writeInt($nameCount);
+            // write segment counter
+            $segmentsFile->writeInt(0);
+
+            $deletableFile = $directory->createFile('deletable');
+            // write counter
+            $deletableFile->writeInt(0);
+        } else {
+            $genFile = $directory->createFile('segments.gen');
+
+            $genFile->writeInt((int)0xFFFFFFFE);
+            // Write generation two times
+            $genFile->writeLong($generation);
+            $genFile->writeLong($generation);
+
+            $segmentsFile = $directory->createFile(Zend_Search_Lucene::getSegmentFileName($generation));
+            $segmentsFile->writeInt((int)0xFFFFFFFD);
+
+            // write version (initialized by current time)
+            $segmentsFile->writeLong(round(microtime(true)));
+
+            // write name counter
+            $segmentsFile->writeInt($nameCount);
+            // write segment counter
+            $segmentsFile->writeInt(0);
+        }
+    }
+
+    /**
+     * Open the index for writing
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @param array $segmentInfos
+     * @param integer $targetFormatVersion
+     * @param Zend_Search_Lucene_Storage_File $cleanUpLock
+     */
+    public function __construct(Zend_Search_Lucene_Storage_Directory $directory, &$segmentInfos, $targetFormatVersion)
+    {
+        $this->_directory           = $directory;
+        $this->_segmentInfos        = &$segmentInfos;
+        $this->_targetFormatVersion = $targetFormatVersion;
+    }
+
+    /**
+     * Adds a document to this index.
+     *
+     * @param Zend_Search_Lucene_Document $document
+     */
+    public function addDocument(Zend_Search_Lucene_Document $document)
+    {
+        if ($this->_currentSegment === null) {
+            $this->_currentSegment =
+                new Zend_Search_Lucene_Index_SegmentWriter_DocumentWriter($this->_directory, $this->_newSegmentName());
+        }
+        $this->_currentSegment->addDocument($document);
+
+        if ($this->_currentSegment->count() >= $this->maxBufferedDocs) {
+            $this->commit();
+        }
+
+        $this->_maybeMergeSegments();
+
+        $this->_versionUpdate++;
+    }
+
+
+    /**
+     * Check if we have anything to merge
+     *
+     * @return boolean
+     */
+    private function _hasAnythingToMerge()
+    {
+        $segmentSizes = array();
+        foreach ($this->_segmentInfos as $segName => $segmentInfo) {
+            $segmentSizes[$segName] = $segmentInfo->count();
+        }
+
+        $mergePool   = array();
+        $poolSize    = 0;
+        $sizeToMerge = $this->maxBufferedDocs;
+        asort($segmentSizes, SORT_NUMERIC);
+        foreach ($segmentSizes as $segName => $size) {
+            // Check, if segment comes into a new merging block
+            while ($size >= $sizeToMerge) {
+                // Merge previous block if it's large enough
+                if ($poolSize >= $sizeToMerge) {
+                    return true;
+                }
+                $mergePool   = array();
+                $poolSize    = 0;
+
+                $sizeToMerge *= $this->mergeFactor;
+
+                if ($sizeToMerge > $this->maxMergeDocs) {
+                    return false;
+                }
+            }
+
+            $mergePool[] = $this->_segmentInfos[$segName];
+            $poolSize += $size;
+        }
+
+        if ($poolSize >= $sizeToMerge) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Merge segments if necessary
+     */
+    private function _maybeMergeSegments()
+    {
+        if (Zend_Search_Lucene_LockManager::obtainOptimizationLock($this->_directory) === false) {
+            return;
+        }
+
+        if (!$this->_hasAnythingToMerge()) {
+            Zend_Search_Lucene_LockManager::releaseOptimizationLock($this->_directory);
+            return;
+        }
+
+        // Update segments list to be sure all segments are not merged yet by another process
+        //
+        // Segment merging functionality is concentrated in this class and surrounded
+        // by optimization lock obtaining/releasing.
+        // _updateSegments() refreshes segments list from the latest index generation.
+        // So only new segments can be added to the index while we are merging some already existing
+        // segments.
+        // Newly added segments will be also included into the index by the _updateSegments() call
+        // either by another process or by the current process with the commit() call at the end of _mergeSegments() method.
+        // That's guaranteed by the serialisation of _updateSegments() execution using exclusive locks.
+        $this->_updateSegments();
+
+        // Perform standard auto-optimization procedure
+        $segmentSizes = array();
+        foreach ($this->_segmentInfos as $segName => $segmentInfo) {
+            $segmentSizes[$segName] = $segmentInfo->count();
+        }
+
+        $mergePool   = array();
+        $poolSize    = 0;
+        $sizeToMerge = $this->maxBufferedDocs;
+        asort($segmentSizes, SORT_NUMERIC);
+        foreach ($segmentSizes as $segName => $size) {
+            // Check, if segment comes into a new merging block
+            while ($size >= $sizeToMerge) {
+                // Merge previous block if it's large enough
+                if ($poolSize >= $sizeToMerge) {
+                    $this->_mergeSegments($mergePool);
+                }
+                $mergePool   = array();
+                $poolSize    = 0;
+
+                $sizeToMerge *= $this->mergeFactor;
+
+                if ($sizeToMerge > $this->maxMergeDocs) {
+                    Zend_Search_Lucene_LockManager::releaseOptimizationLock($this->_directory);
+                    return;
+                }
+            }
+
+            $mergePool[] = $this->_segmentInfos[$segName];
+            $poolSize += $size;
+        }
+
+        if ($poolSize >= $sizeToMerge) {
+            $this->_mergeSegments($mergePool);
+        }
+
+        Zend_Search_Lucene_LockManager::releaseOptimizationLock($this->_directory);
+    }
+
+    /**
+     * Merge specified segments
+     *
+     * $segments is an array of SegmentInfo objects
+     *
+     * @param array $segments
+     */
+    private function _mergeSegments($segments)
+    {
+        $newName = $this->_newSegmentName();
+        $merger = new Zend_Search_Lucene_Index_SegmentMerger($this->_directory,
+                                                             $newName);
+        foreach ($segments as $segmentInfo) {
+            $merger->addSource($segmentInfo);
+            $this->_segmentsToDelete[$segmentInfo->getName()] = $segmentInfo->getName();
+        }
+
+        $newSegment = $merger->merge();
+        if ($newSegment !== null) {
+            $this->_newSegments[$newSegment->getName()] = $newSegment;
+        }
+
+        $this->commit();
+    }
+
+    /**
+     * Update segments file by adding current segment to a list
+     *
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private function _updateSegments()
+    {
+        // Get an exclusive index lock
+        Zend_Search_Lucene_LockManager::obtainWriteLock($this->_directory);
+
+        // Write down changes for the segments
+        foreach ($this->_segmentInfos as $segInfo) {
+            $segInfo->writeChanges();
+        }
+
+
+        $generation = Zend_Search_Lucene::getActualGeneration($this->_directory);
+        $segmentsFile   = $this->_directory->getFileObject(Zend_Search_Lucene::getSegmentFileName($generation), false);
+        $newSegmentFile = $this->_directory->createFile(Zend_Search_Lucene::getSegmentFileName(++$generation), false);
+
+        try {
+            $genFile = $this->_directory->getFileObject('segments.gen', false);
+        } catch (Zend_Search_Lucene_Exception $e) {
+            if (strpos($e->getMessage(), 'is not readable') !== false) {
+                $genFile = $this->_directory->createFile('segments.gen');
+            } else {
+                throw $e;
+            }
+        }
+
+        $genFile->writeInt((int)0xFFFFFFFE);
+        // Write generation (first copy)
+        $genFile->writeLong($generation);
+
+        try {
+            // Write format marker
+            if ($this->_targetFormatVersion == Zend_Search_Lucene::FORMAT_2_1) {
+                $newSegmentFile->writeInt((int)0xFFFFFFFD);
+            } else if ($this->_targetFormatVersion == Zend_Search_Lucene::FORMAT_2_3) {
+                $newSegmentFile->writeInt((int)0xFFFFFFFC);
+            }
+
+            // Read src file format identifier
+            $format = $segmentsFile->readInt();
+            if ($format == (int)0xFFFFFFFF) {
+                $srcFormat = Zend_Search_Lucene::FORMAT_PRE_2_1;
+            } else if ($format == (int)0xFFFFFFFD) {
+                $srcFormat = Zend_Search_Lucene::FORMAT_2_1;
+            } else if ($format == (int)0xFFFFFFFC) {
+                $srcFormat = Zend_Search_Lucene::FORMAT_2_3;
+            } else {
+                throw new Zend_Search_Lucene_Exception('Unsupported segments file format');
+            }
+
+            $version = $segmentsFile->readLong() + $this->_versionUpdate;
+            $this->_versionUpdate = 0;
+            $newSegmentFile->writeLong($version);
+
+            // Write segment name counter
+            $newSegmentFile->writeInt($segmentsFile->readInt());
+
+            // Get number of segments offset
+            $numOfSegmentsOffset = $newSegmentFile->tell();
+            // Write dummy data (segment counter)
+            $newSegmentFile->writeInt(0);
+
+            // Read number of segemnts
+            $segmentsCount = $segmentsFile->readInt();
+
+            $segments = array();
+            for ($count = 0; $count < $segmentsCount; $count++) {
+                $segName = $segmentsFile->readString();
+                $segSize = $segmentsFile->readInt();
+
+                if ($srcFormat == Zend_Search_Lucene::FORMAT_PRE_2_1) {
+                    // pre-2.1 index format
+                    $delGen            = 0;
+                    $hasSingleNormFile = false;
+                    $numField          = (int)0xFFFFFFFF;
+                    $isCompoundByte    = 0;
+                    $docStoreOptions   = null;
+                } else {
+                    $delGen = $segmentsFile->readLong();
+
+                    if ($srcFormat == Zend_Search_Lucene::FORMAT_2_3) {
+                        $docStoreOffset = $segmentsFile->readInt();
+
+                        if ($docStoreOffset != (int)0xFFFFFFFF) {
+                            $docStoreSegment        = $segmentsFile->readString();
+                            $docStoreIsCompoundFile = $segmentsFile->readByte();
+
+                            $docStoreOptions = array('offset'     => $docStoreOffset,
+                                                     'segment'    => $docStoreSegment,
+                                                     'isCompound' => ($docStoreIsCompoundFile == 1));
+                        } else {
+                            $docStoreOptions = null;
+                        }
+                    } else {
+                        $docStoreOptions = null;
+                    }
+
+                    $hasSingleNormFile = $segmentsFile->readByte();
+                    $numField          = $segmentsFile->readInt();
+
+                    $normGens = array();
+                    if ($numField != (int)0xFFFFFFFF) {
+                        for ($count1 = 0; $count1 < $numField; $count1++) {
+                            $normGens[] = $segmentsFile->readLong();
+                        }
+                    }
+                    $isCompoundByte    = $segmentsFile->readByte();
+                }
+
+                if (!in_array($segName, $this->_segmentsToDelete)) {
+                    // Load segment if necessary
+                    if (!isset($this->_segmentInfos[$segName])) {
+                        if ($isCompoundByte == 0xFF) {
+                            // The segment is not a compound file
+                            $isCompound = false;
+                        } else if ($isCompoundByte == 0x00) {
+                            // The status is unknown
+                            $isCompound = null;
+                        } else if ($isCompoundByte == 0x01) {
+                            // The segment is a compound file
+                            $isCompound = true;
+                        }
+
+                        $this->_segmentInfos[$segName] =
+                                    new Zend_Search_Lucene_Index_SegmentInfo($this->_directory,
+                                                                             $segName,
+                                                                             $segSize,
+                                                                             $delGen,
+                                                                             $docStoreOptions,
+                                                                             $hasSingleNormFile,
+                                                                             $isCompound);
+                    } else {
+                        // Retrieve actual deletions file generation number
+                        $delGen = $this->_segmentInfos[$segName]->getDelGen();
+                    }
+
+                    $newSegmentFile->writeString($segName);
+                    $newSegmentFile->writeInt($segSize);
+                    $newSegmentFile->writeLong($delGen);
+                    if ($this->_targetFormatVersion == Zend_Search_Lucene::FORMAT_2_3) {
+                        if ($docStoreOptions !== null) {
+                            $newSegmentFile->writeInt($docStoreOffset);
+                            $newSegmentFile->writeString($docStoreSegment);
+                            $newSegmentFile->writeByte($docStoreIsCompoundFile);
+                        } else {
+                            // Set DocStoreOffset to -1
+                            $newSegmentFile->writeInt((int)0xFFFFFFFF);
+                        }
+                    } else if ($docStoreOptions !== null) {
+                        // Release index write lock
+                        Zend_Search_Lucene_LockManager::releaseWriteLock($this->_directory);
+
+                        throw new Zend_Search_Lucene_Exception('Index conversion to lower format version is not supported.');
+                    }
+
+                    $newSegmentFile->writeByte($hasSingleNormFile);
+                    $newSegmentFile->writeInt($numField);
+                    if ($numField != (int)0xFFFFFFFF) {
+                        foreach ($normGens as $normGen) {
+                            $newSegmentFile->writeLong($normGen);
+                        }
+                    }
+                    $newSegmentFile->writeByte($isCompoundByte);
+
+                    $segments[$segName] = $segSize;
+                }
+            }
+            $segmentsFile->close();
+
+            $segmentsCount = count($segments) + count($this->_newSegments);
+
+            foreach ($this->_newSegments as $segName => $segmentInfo) {
+                $newSegmentFile->writeString($segName);
+                $newSegmentFile->writeInt($segmentInfo->count());
+
+                // delete file generation: -1 (there is no delete file yet)
+                $newSegmentFile->writeInt((int)0xFFFFFFFF);$newSegmentFile->writeInt((int)0xFFFFFFFF);
+                if ($this->_targetFormatVersion == Zend_Search_Lucene::FORMAT_2_3) {
+                    // docStoreOffset: -1 (segment doesn't use shared doc store)
+                    $newSegmentFile->writeInt((int)0xFFFFFFFF);
+                }
+                // HasSingleNormFile
+                $newSegmentFile->writeByte($segmentInfo->hasSingleNormFile());
+                // NumField
+                $newSegmentFile->writeInt((int)0xFFFFFFFF);
+                // IsCompoundFile
+                $newSegmentFile->writeByte($segmentInfo->isCompound() ? 1 : -1);
+
+                $segments[$segmentInfo->getName()] = $segmentInfo->count();
+                $this->_segmentInfos[$segName] = $segmentInfo;
+            }
+            $this->_newSegments = array();
+
+            $newSegmentFile->seek($numOfSegmentsOffset);
+            $newSegmentFile->writeInt($segmentsCount);  // Update segments count
+            $newSegmentFile->close();
+        } catch (Exception $e) {
+            /** Restore previous index generation */
+            $generation--;
+            $genFile->seek(4, SEEK_SET);
+            // Write generation number twice
+            $genFile->writeLong($generation); $genFile->writeLong($generation);
+
+            // Release index write lock
+            Zend_Search_Lucene_LockManager::releaseWriteLock($this->_directory);
+
+            // Throw the exception
+            throw $e;
+        }
+
+        // Write generation (second copy)
+        $genFile->writeLong($generation);
+
+
+        // Check if another update or read process is not running now
+        // If yes, skip clean-up procedure
+        if (Zend_Search_Lucene_LockManager::escalateReadLock($this->_directory)) {
+            /**
+             * Clean-up directory
+             */
+            $filesToDelete = array();
+            $filesTypes    = array();
+            $filesNumbers  = array();
+
+            // list of .del files of currently used segments
+            // each segment can have several generations of .del files
+            // only last should not be deleted
+            $delFiles = array();
+
+            foreach ($this->_directory->fileList() as $file) {
+                if ($file == 'deletable') {
+                    // 'deletable' file
+                    $filesToDelete[] = $file;
+                    $filesTypes[]    = 0; // delete this file first, since it's not used starting from Lucene v2.1
+                    $filesNumbers[]  = 0;
+                } else if ($file == 'segments') {
+                    // 'segments' file
+                    $filesToDelete[] = $file;
+                    $filesTypes[]    = 1; // second file to be deleted "zero" version of segments file (Lucene pre-2.1)
+                    $filesNumbers[]  = 0;
+                } else if (preg_match('/^segments_[a-zA-Z0-9]+$/i', $file)) {
+                    // 'segments_xxx' file
+                    // Check if it's not a just created generation file
+                    if ($file != Zend_Search_Lucene::getSegmentFileName($generation)) {
+                        $filesToDelete[] = $file;
+                        $filesTypes[]    = 2; // first group of files for deletions
+                        $filesNumbers[]  = (int)base_convert(substr($file, 9), 36, 10); // ordered by segment generation numbers
+                    }
+                } else if (preg_match('/(^_([a-zA-Z0-9]+))\.f\d+$/i', $file, $matches)) {
+                    // one of per segment files ('<segment_name>.f<decimal_number>')
+                    // Check if it's not one of the segments in the current segments set
+                    if (!isset($segments[$matches[1]])) {
+                        $filesToDelete[] = $file;
+                        $filesTypes[]    = 3; // second group of files for deletions
+                        $filesNumbers[]  = (int)base_convert($matches[2], 36, 10); // order by segment number
+                    }
+                } else if (preg_match('/(^_([a-zA-Z0-9]+))(_([a-zA-Z0-9]+))\.del$/i', $file, $matches)) {
+                    // one of per segment files ('<segment_name>_<del_generation>.del' where <segment_name> is '_<segment_number>')
+                    // Check if it's not one of the segments in the current segments set
+                    if (!isset($segments[$matches[1]])) {
+                        $filesToDelete[] = $file;
+                        $filesTypes[]    = 3; // second group of files for deletions
+                        $filesNumbers[]  = (int)base_convert($matches[2], 36, 10); // order by segment number
+                    } else {
+                        $segmentNumber = (int)base_convert($matches[2], 36, 10);
+                        $delGeneration = (int)base_convert($matches[4], 36, 10);
+                        if (!isset($delFiles[$segmentNumber])) {
+                            $delFiles[$segmentNumber] = array();
+                        }
+                        $delFiles[$segmentNumber][$delGeneration] = $file;
+                    }
+                } else if (isset(self::$_indexExtensions[substr($file, strlen($file)-4)])) {
+                    // one of per segment files ('<segment_name>.<ext>')
+                    $segmentName = substr($file, 0, strlen($file) - 4);
+                    // Check if it's not one of the segments in the current segments set
+                    if (!isset($segments[$segmentName])  &&
+                        ($this->_currentSegment === null  ||  $this->_currentSegment->getName() != $segmentName)) {
+                        $filesToDelete[] = $file;
+                        $filesTypes[]    = 3; // second group of files for deletions
+                        $filesNumbers[]  = (int)base_convert(substr($file, 1 /* skip '_' */, strlen($file)-5), 36, 10); // order by segment number
+                    }
+                }
+            }
+
+            $maxGenNumber = 0;
+            // process .del files of currently used segments
+            foreach ($delFiles as $segmentNumber => $segmentDelFiles) {
+                ksort($delFiles[$segmentNumber], SORT_NUMERIC);
+                array_pop($delFiles[$segmentNumber]); // remove last delete file generation from candidates for deleting
+
+                end($delFiles[$segmentNumber]);
+                $lastGenNumber = key($delFiles[$segmentNumber]);
+                if ($lastGenNumber > $maxGenNumber) {
+                    $maxGenNumber = $lastGenNumber;
+                }
+            }
+            foreach ($delFiles as $segmentNumber => $segmentDelFiles) {
+                foreach ($segmentDelFiles as $delGeneration => $file) {
+                        $filesToDelete[] = $file;
+                        $filesTypes[]    = 4; // third group of files for deletions
+                        $filesNumbers[]  = $segmentNumber*$maxGenNumber + $delGeneration; // order by <segment_number>,<del_generation> pair
+                }
+            }
+
+            // Reorder files for deleting
+            array_multisort($filesTypes,    SORT_ASC, SORT_NUMERIC,
+                            $filesNumbers,  SORT_ASC, SORT_NUMERIC,
+                            $filesToDelete, SORT_ASC, SORT_STRING);
+
+            foreach ($filesToDelete as $file) {
+                try {
+                    /** Skip shared docstore segments deleting */
+                    /** @todo Process '.cfx' files to check if them are already unused */
+                    if (substr($file, strlen($file)-4) != '.cfx') {
+                        $this->_directory->deleteFile($file);
+                    }
+                } catch (Zend_Search_Lucene_Exception $e) {
+                    if (strpos($e->getMessage(), 'Can\'t delete file') === false) {
+                        // That's not "file is under processing or already deleted" exception
+                        // Pass it through
+                        throw $e;
+                    }
+                }
+            }
+
+            // Return read lock into the previous state
+            Zend_Search_Lucene_LockManager::deEscalateReadLock($this->_directory);
+        } else {
+            // Only release resources if another index reader is running now
+            foreach ($this->_segmentsToDelete as $segName) {
+                foreach (self::$_indexExtensions as $ext) {
+                    $this->_directory->purgeFile($segName . $ext);
+                }
+            }
+        }
+
+        // Clean-up _segmentsToDelete container
+        $this->_segmentsToDelete = array();
+
+
+        // Release index write lock
+        Zend_Search_Lucene_LockManager::releaseWriteLock($this->_directory);
+
+        // Remove unused segments from segments list
+        foreach ($this->_segmentInfos as $segName => $segmentInfo) {
+            if (!isset($segments[$segName])) {
+                unset($this->_segmentInfos[$segName]);
+            }
+        }
+    }
+
+    /**
+     * Commit current changes
+     */
+    public function commit()
+    {
+        if ($this->_currentSegment !== null) {
+            $newSegment = $this->_currentSegment->close();
+            if ($newSegment !== null) {
+                $this->_newSegments[$newSegment->getName()] = $newSegment;
+            }
+            $this->_currentSegment = null;
+        }
+
+        $this->_updateSegments();
+    }
+
+
+    /**
+     * Merges the provided indexes into this index.
+     *
+     * @param array $readers
+     * @return void
+     */
+    public function addIndexes($readers)
+    {
+        /**
+         * @todo implementation
+         */
+    }
+
+    /**
+     * Merges all segments together into new one
+     *
+     * Returns true on success and false if another optimization or auto-optimization process
+     * is running now
+     *
+     * @return boolean
+     */
+    public function optimize()
+    {
+        if (Zend_Search_Lucene_LockManager::obtainOptimizationLock($this->_directory) === false) {
+            return false;
+        }
+
+        // Update segments list to be sure all segments are not merged yet by another process
+        //
+        // Segment merging functionality is concentrated in this class and surrounded
+        // by optimization lock obtaining/releasing.
+        // _updateSegments() refreshes segments list from the latest index generation.
+        // So only new segments can be added to the index while we are merging some already existing
+        // segments.
+        // Newly added segments will be also included into the index by the _updateSegments() call
+        // either by another process or by the current process with the commit() call at the end of _mergeSegments() method.
+        // That's guaranteed by the serialisation of _updateSegments() execution using exclusive locks.
+        $this->_updateSegments();
+
+        $this->_mergeSegments($this->_segmentInfos);
+
+        Zend_Search_Lucene_LockManager::releaseOptimizationLock($this->_directory);
+
+        return true;
+    }
+
+    /**
+     * Get name for new segment
+     *
+     * @return string
+     */
+    private function _newSegmentName()
+    {
+        Zend_Search_Lucene_LockManager::obtainWriteLock($this->_directory);
+
+        $generation = Zend_Search_Lucene::getActualGeneration($this->_directory);
+        $segmentsFile = $this->_directory->getFileObject(Zend_Search_Lucene::getSegmentFileName($generation), false);
+
+        $segmentsFile->seek(12); // 12 = 4 (int, file format marker) + 8 (long, index version)
+        $segmentNameCounter = $segmentsFile->readInt();
+
+        $segmentsFile->seek(12); // 12 = 4 (int, file format marker) + 8 (long, index version)
+        $segmentsFile->writeInt($segmentNameCounter + 1);
+
+        // Flash output to guarantee that wrong value will not be loaded between unlock and
+        // return (which calls $segmentsFile destructor)
+        $segmentsFile->flush();
+
+        Zend_Search_Lucene_LockManager::releaseWriteLock($this->_directory);
+
+        return '_' . base_convert($segmentNameCounter, 10, 36);
+    }
+
+}
diff --git a/inc/lib/Zend/Search/Lucene/Interface.php b/inc/lib/Zend/Search/Lucene/Interface.php
new file mode 100644 (file)
index 0000000..0052b14
--- /dev/null
@@ -0,0 +1,404 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Interface.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Index_TermsStream_Interface */
+require_once 'Zend/Search/Lucene/Index/TermsStream/Interface.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+interface Zend_Search_Lucene_Interface extends Zend_Search_Lucene_Index_TermsStream_Interface
+{
+    /**
+     * Get current generation number
+     *
+     * Returns generation number
+     * 0 means pre-2.1 index format
+     * -1 means there are no segments files.
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @return integer
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public static function getActualGeneration(Zend_Search_Lucene_Storage_Directory $directory);
+
+    /**
+     * Get segments file name
+     *
+     * @param integer $generation
+     * @return string
+     */
+    public static function getSegmentFileName($generation);
+
+    /**
+     * Get index format version
+     *
+     * @return integer
+     */
+    public function getFormatVersion();
+
+    /**
+     * Set index format version.
+     * Index is converted to this format at the nearest upfdate time
+     *
+     * @param int $formatVersion
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function setFormatVersion($formatVersion);
+
+    /**
+     * Returns the Zend_Search_Lucene_Storage_Directory instance for this index.
+     *
+     * @return Zend_Search_Lucene_Storage_Directory
+     */
+    public function getDirectory();
+
+    /**
+     * Returns the total number of documents in this index (including deleted documents).
+     *
+     * @return integer
+     */
+    public function count();
+
+    /**
+     * Returns one greater than the largest possible document number.
+     * This may be used to, e.g., determine how big to allocate a structure which will have
+     * an element for every document number in an index.
+     *
+     * @return integer
+     */
+    public function maxDoc();
+
+    /**
+     * Returns the total number of non-deleted documents in this index.
+     *
+     * @return integer
+     */
+    public function numDocs();
+
+    /**
+     * Checks, that document is deleted
+     *
+     * @param integer $id
+     * @return boolean
+     * @throws Zend_Search_Lucene_Exception    Exception is thrown if $id is out of the range
+     */
+    public function isDeleted($id);
+
+    /**
+     * Set default search field.
+     *
+     * Null means, that search is performed through all fields by default
+     *
+     * Default value is null
+     *
+     * @param string $fieldName
+     */
+    public static function setDefaultSearchField($fieldName);
+
+    /**
+     * Get default search field.
+     *
+     * Null means, that search is performed through all fields by default
+     *
+     * @return string
+     */
+    public static function getDefaultSearchField();
+
+    /**
+     * Set result set limit.
+     *
+     * 0 (default) means no limit
+     *
+     * @param integer $limit
+     */
+    public static function setResultSetLimit($limit);
+
+    /**
+     * Set result set limit.
+     *
+     * 0 means no limit
+     *
+     * @return integer
+     */
+    public static function getResultSetLimit();
+
+    /**
+     * Retrieve index maxBufferedDocs option
+     *
+     * maxBufferedDocs is a minimal number of documents required before
+     * the buffered in-memory documents are written into a new Segment
+     *
+     * Default value is 10
+     *
+     * @return integer
+     */
+    public function getMaxBufferedDocs();
+
+    /**
+     * Set index maxBufferedDocs option
+     *
+     * maxBufferedDocs is a minimal number of documents required before
+     * the buffered in-memory documents are written into a new Segment
+     *
+     * Default value is 10
+     *
+     * @param integer $maxBufferedDocs
+     */
+    public function setMaxBufferedDocs($maxBufferedDocs);
+
+    /**
+     * Retrieve index maxMergeDocs option
+     *
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().
+     * Small values (e.g., less than 10,000) are best for interactive indexing,
+     * as this limits the length of pauses while indexing to a few seconds.
+     * Larger values are best for batched indexing and speedier searches.
+     *
+     * Default value is PHP_INT_MAX
+     *
+     * @return integer
+     */
+    public function getMaxMergeDocs();
+
+    /**
+     * Set index maxMergeDocs option
+     *
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().
+     * Small values (e.g., less than 10,000) are best for interactive indexing,
+     * as this limits the length of pauses while indexing to a few seconds.
+     * Larger values are best for batched indexing and speedier searches.
+     *
+     * Default value is PHP_INT_MAX
+     *
+     * @param integer $maxMergeDocs
+     */
+    public function setMaxMergeDocs($maxMergeDocs);
+
+    /**
+     * Retrieve index mergeFactor option
+     *
+     * mergeFactor determines how often segment indices are merged by addDocument().
+     * With smaller values, less RAM is used while indexing,
+     * and searches on unoptimized indices are faster,
+     * but indexing speed is slower.
+     * With larger values, more RAM is used during indexing,
+     * and while searches on unoptimized indices are slower,
+     * indexing is faster.
+     * Thus larger values (> 10) are best for batch index creation,
+     * and smaller values (< 10) for indices that are interactively maintained.
+     *
+     * Default value is 10
+     *
+     * @return integer
+     */
+    public function getMergeFactor();
+
+    /**
+     * Set index mergeFactor option
+     *
+     * mergeFactor determines how often segment indices are merged by addDocument().
+     * With smaller values, less RAM is used while indexing,
+     * and searches on unoptimized indices are faster,
+     * but indexing speed is slower.
+     * With larger values, more RAM is used during indexing,
+     * and while searches on unoptimized indices are slower,
+     * indexing is faster.
+     * Thus larger values (> 10) are best for batch index creation,
+     * and smaller values (< 10) for indices that are interactively maintained.
+     *
+     * Default value is 10
+     *
+     * @param integer $maxMergeDocs
+     */
+    public function setMergeFactor($mergeFactor);
+
+    /**
+     * Performs a query against the index and returns an array
+     * of Zend_Search_Lucene_Search_QueryHit objects.
+     * Input is a string or Zend_Search_Lucene_Search_Query.
+     *
+     * @param mixed $query
+     * @return array Zend_Search_Lucene_Search_QueryHit
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function find($query);
+
+    /**
+     * Returns a list of all unique field names that exist in this index.
+     *
+     * @param boolean $indexed
+     * @return array
+     */
+    public function getFieldNames($indexed = false);
+
+    /**
+     * Returns a Zend_Search_Lucene_Document object for the document
+     * number $id in this index.
+     *
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id
+     * @return Zend_Search_Lucene_Document
+     */
+    public function getDocument($id);
+
+    /**
+     * Returns true if index contain documents with specified term.
+     *
+     * Is used for query optimization.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @return boolean
+     */
+    public function hasTerm(Zend_Search_Lucene_Index_Term $term);
+
+    /**
+     * Returns IDs of all the documents containing term.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return array
+     */
+    public function termDocs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null);
+
+    /**
+     * Returns documents filter for all documents containing term.
+     *
+     * It performs the same operation as termDocs, but return result as
+     * Zend_Search_Lucene_Index_DocsFilter object
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return Zend_Search_Lucene_Index_DocsFilter
+     */
+    public function termDocsFilter(Zend_Search_Lucene_Index_Term $term, $docsFilter = null);
+
+    /**
+     * Returns an array of all term freqs.
+     * Return array structure: array( docId => freq, ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return integer
+     */
+    public function termFreqs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null);
+
+    /**
+     * Returns an array of all term positions in the documents.
+     * Return array structure: array( docId => array( pos1, pos2, ...), ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return array
+     */
+    public function termPositions(Zend_Search_Lucene_Index_Term $term, $docsFilter = null);
+
+    /**
+     * Returns the number of documents in this index containing the $term.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @return integer
+     */
+    public function docFreq(Zend_Search_Lucene_Index_Term $term);
+
+    /**
+     * Retrive similarity used by index reader
+     *
+     * @return Zend_Search_Lucene_Search_Similarity
+     */
+    public function getSimilarity();
+
+    /**
+     * Returns a normalization factor for "field, document" pair.
+     *
+     * @param integer $id
+     * @param string $fieldName
+     * @return float
+     */
+    public function norm($id, $fieldName);
+
+    /**
+     * Returns true if any documents have been deleted from this index.
+     *
+     * @return boolean
+     */
+    public function hasDeletions();
+
+    /**
+     * Deletes a document from the index.
+     * $id is an internal document id
+     *
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function delete($id);
+
+    /**
+     * Adds a document to this index.
+     *
+     * @param Zend_Search_Lucene_Document $document
+     */
+    public function addDocument(Zend_Search_Lucene_Document $document);
+
+    /**
+     * Commit changes resulting from delete() or undeleteAll() operations.
+     */
+    public function commit();
+
+    /**
+     * Optimize index.
+     *
+     * Merges all segments into one
+     */
+    public function optimize();
+
+    /**
+     * Returns an array of all terms in this index.
+     *
+     * @return array
+     */
+    public function terms();
+
+    /**
+     * Undeletes all documents currently marked as deleted in this index.
+     */
+    public function undeleteAll();
+
+
+    /**
+     * Add reference to the index object
+     *
+     * @internal
+     */
+    public function addReference();
+
+    /**
+     * Remove reference from the index object
+     *
+     * When reference count becomes zero, index is closed and resources are cleaned up
+     *
+     * @internal
+     */
+    public function removeReference();
+}
diff --git a/inc/lib/Zend/Search/Lucene/LockManager.php b/inc/lib/Zend/Search/Lucene/LockManager.php
new file mode 100644 (file)
index 0000000..c9b639d
--- /dev/null
@@ -0,0 +1,236 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: LockManager.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Storage_Directory */
+require_once 'Zend/Search/Lucene/Storage/Directory.php';
+
+/** Zend_Search_Lucene_Storage_File */
+require_once 'Zend/Search/Lucene/Storage/File.php';
+
+/**
+ * This is an utility class which provides index locks processing functionality
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_LockManager
+{
+    /**
+     * consts for name of file to show lock status
+     */
+    const WRITE_LOCK_FILE                = 'write.lock.file';
+    const READ_LOCK_FILE                 = 'read.lock.file';
+    const READ_LOCK_PROCESSING_LOCK_FILE = 'read-lock-processing.lock.file';
+    const OPTIMIZATION_LOCK_FILE         = 'optimization.lock.file';
+
+    /**
+     * Obtain exclusive write lock on the index
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     * @return Zend_Search_Lucene_Storage_File
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public static function obtainWriteLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->createFile(self::WRITE_LOCK_FILE);
+        if (!$lock->lock(LOCK_EX)) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Can\'t obtain exclusive index lock');
+        }
+        return $lock;
+    }
+
+    /**
+     * Release exclusive write lock
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     */
+    public static function releaseWriteLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->getFileObject(self::WRITE_LOCK_FILE);
+        $lock->unlock();
+    }
+
+    /**
+     * Obtain the exclusive "read escalation/de-escalation" lock
+     *
+     * Required to protect the escalate/de-escalate read lock process
+     * on GFS (and potentially other) mounted filesystems.
+     *
+     * Why we need this:
+     *  While GFS supports cluster-wide locking via flock(), it's
+     *  implementation isn't quite what it should be.  The locking
+     *  semantics that work consistently on a local filesystem tend to
+     *  fail on GFS mounted filesystems.  This appears to be a design defect
+     *  in the implementation of GFS.  How this manifests itself is that
+     *  conditional promotion of a shared lock to exclusive will always
+     *  fail, lock release requests are honored but not immediately
+     *  processed (causing erratic failures of subsequent conditional
+     *  requests) and the releasing of the exclusive lock before the
+     *  shared lock is set when a lock is demoted (which can open a window
+     *  of opportunity for another process to gain an exclusive lock when
+     *  it shoudln't be allowed to).
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     * @return Zend_Search_Lucene_Storage_File
+     * @throws Zend_Search_Lucene_Exception
+     */
+    private static function _startReadLockProcessing(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->createFile(self::READ_LOCK_PROCESSING_LOCK_FILE);
+        if (!$lock->lock(LOCK_EX)) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Can\'t obtain exclusive lock for the read lock processing file');
+        }
+        return $lock;
+    }
+
+    /**
+     * Release the exclusive "read escalation/de-escalation" lock
+     *
+     * Required to protect the escalate/de-escalate read lock process
+     * on GFS (and potentially other) mounted filesystems.
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     */
+    private static function _stopReadLockProcessing(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->getFileObject(self::READ_LOCK_PROCESSING_LOCK_FILE);
+        $lock->unlock();
+    }
+
+
+    /**
+     * Obtain shared read lock on the index
+     *
+     * It doesn't block other read or update processes, but prevent index from the premature cleaning-up
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $defaultLockDirectory
+     * @return Zend_Search_Lucene_Storage_File
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public static function obtainReadLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->createFile(self::READ_LOCK_FILE);
+        if (!$lock->lock(LOCK_SH)) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Can\'t obtain shared reading index lock');
+        }
+        return $lock;
+    }
+
+    /**
+     * Release shared read lock
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     */
+    public static function releaseReadLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->getFileObject(self::READ_LOCK_FILE);
+        $lock->unlock();
+    }
+
+    /**
+     * Escalate Read lock to exclusive level
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     * @return boolean
+     */
+    public static function escalateReadLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        self::_startReadLockProcessing($lockDirectory);
+
+        $lock = $lockDirectory->getFileObject(self::READ_LOCK_FILE);
+
+        // First, release the shared lock for the benefit of GFS since
+        // it will fail the conditional request to promote the lock to
+        // "exclusive" while the shared lock is held (even when we are
+        // the only holder).
+        $lock->unlock();
+
+        // GFS is really poor.  While the above "unlock" returns, GFS
+        // doesn't clean up it's tables right away (which will potentially
+        // cause the conditional locking for the "exclusive" lock to fail.
+        // We will retry the conditional lock request several times on a
+        // failure to get past this.  The performance hit is negligible
+        // in the grand scheme of things and only will occur with GFS
+        // filesystems or if another local process has the shared lock
+        // on local filesystems.
+        for ($retries = 0; $retries < 10; $retries++) {
+            if ($lock->lock(LOCK_EX, true)) {
+                // Exclusive lock is obtained!
+                self::_stopReadLockProcessing($lockDirectory);
+                return true;
+            }
+
+            // wait 1 microsecond
+            usleep(1);
+        }
+
+        // Restore lock state
+        $lock->lock(LOCK_SH);
+
+        self::_stopReadLockProcessing($lockDirectory);
+        return false;
+    }
+
+    /**
+     * De-escalate Read lock to shared level
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     */
+    public static function deEscalateReadLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->getFileObject(self::READ_LOCK_FILE);
+        $lock->lock(LOCK_SH);
+    }
+
+    /**
+     * Obtain exclusive optimization lock on the index
+     *
+     * Returns lock object on success and false otherwise (doesn't block execution)
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     * @return mixed
+     */
+    public static function obtainOptimizationLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->createFile(self::OPTIMIZATION_LOCK_FILE);
+        if (!$lock->lock(LOCK_EX, true)) {
+            return false;
+        }
+        return $lock;
+    }
+
+    /**
+     * Release exclusive optimization lock
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $lockDirectory
+     */
+    public static function releaseOptimizationLock(Zend_Search_Lucene_Storage_Directory $lockDirectory)
+    {
+        $lock = $lockDirectory->getFileObject(self::OPTIMIZATION_LOCK_FILE);
+        $lock->unlock();
+    }
+
+}
diff --git a/inc/lib/Zend/Search/Lucene/MultiSearcher.php b/inc/lib/Zend/Search/Lucene/MultiSearcher.php
new file mode 100644 (file)
index 0000000..b8c3997
--- /dev/null
@@ -0,0 +1,963 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: MultiSearcher.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+/** Zend_Search_Lucene_TermStreamsPriorityQueue */\r
+require_once 'Zend/Search/Lucene/TermStreamsPriorityQueue.php';\r
+\r
+/** Zend_Search_Lucene_Interface */\r
+require_once 'Zend/Search/Lucene/Interface.php';\r
+\r
+/**\r
+ * Multisearcher allows to search through several independent indexes.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+class Zend_Search_Lucene_Interface_MultiSearcher implements Zend_Search_Lucene_Interface\r
+{\r
+       /**\r
+        * List of indices for searching.\r
+        * Array of Zend_Search_Lucene_Interface objects\r
+        *\r
+        * @var array\r
+        */\r
+       protected $_indices;\r
+\r
+       /**\r
+        * Object constructor.\r
+        *\r
+        * @param array $indices   Arrays of indices for search\r
+        * @throws Zend_Search_Lucene_Exception\r
+        */\r
+       public function __construct($indices = array())\r
+       {\r
+               $this->_indices = $indices;\r
+\r
+               foreach ($this->_indices as $index) {\r
+                       if (!$index instanceof Zend_Search_Lucene_Interface) {\r
+                require_once 'Zend/Search/Lucene/Exception.php';\r
+                throw new Zend_Search_Lucene_Exception('sub-index objects have to implement Zend_Search_Lucene_Interface.');\r
+                       }\r
+               }\r
+       }\r
+\r
+    /**\r
+     * Add index for searching.\r
+     *\r
+     * @param Zend_Search_Lucene_Interface $index\r
+     */\r
+    public function addIndex(Zend_Search_Lucene_Interface $index)\r
+    {\r
+        $this->_indices[] = $index;\r
+    }\r
+\r
+\r
+    /**\r
+     * Get current generation number\r
+     *\r
+     * Returns generation number\r
+     * 0 means pre-2.1 index format\r
+     * -1 means there are no segments files.\r
+     *\r
+     * @param Zend_Search_Lucene_Storage_Directory $directory\r
+     * @return integer\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public static function getActualGeneration(Zend_Search_Lucene_Storage_Directory $directory)\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception("Generation number can't be retrieved for multi-searcher");\r
+    }\r
+\r
+    /**\r
+     * Get segments file name\r
+     *\r
+     * @param integer $generation\r
+     * @return string\r
+     */\r
+    public static function getSegmentFileName($generation)\r
+    {\r
+        return Zend_Search_Lucene::getSegmentFileName($generation);\r
+    }\r
+\r
+    /**\r
+     * Get index format version\r
+     *\r
+     * @return integer\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function getFormatVersion()\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception("Format version can't be retrieved for multi-searcher");\r
+    }\r
+\r
+    /**\r
+     * Set index format version.\r
+     * Index is converted to this format at the nearest upfdate time\r
+     *\r
+     * @param int $formatVersion\r
+     */\r
+    public function setFormatVersion($formatVersion)\r
+    {\r
+       foreach ($this->_indices as $index) {\r
+               $index->setFormatVersion($formatVersion);\r
+       }\r
+    }\r
+\r
+    /**\r
+     * Returns the Zend_Search_Lucene_Storage_Directory instance for this index.\r
+     *\r
+     * @return Zend_Search_Lucene_Storage_Directory\r
+     */\r
+    public function getDirectory()\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception("Index directory can't be retrieved for multi-searcher");\r
+    }\r
+\r
+    /**\r
+     * Returns the total number of documents in this index (including deleted documents).\r
+     *\r
+     * @return integer\r
+     */\r
+    public function count()\r
+    {\r
+       $count = 0;\r
+\r
+       foreach ($this->_indices as $index) {\r
+               $count += $this->_indices->count();\r
+       }\r
+\r
+       return $count;\r
+    }\r
+\r
+    /**\r
+     * Returns one greater than the largest possible document number.\r
+     * This may be used to, e.g., determine how big to allocate a structure which will have\r
+     * an element for every document number in an index.\r
+     *\r
+     * @return integer\r
+     */\r
+    public function maxDoc()\r
+    {\r
+        return $this->count();\r
+    }\r
+\r
+    /**\r
+     * Returns the total number of non-deleted documents in this index.\r
+     *\r
+     * @return integer\r
+     */\r
+    public function numDocs()\r
+    {\r
+        $docs = 0;\r
+\r
+        foreach ($this->_indices as $index) {\r
+            $docs += $this->_indices->numDocs();\r
+        }\r
+\r
+        return $docs;\r
+    }\r
+\r
+    /**\r
+     * Checks, that document is deleted\r
+     *\r
+     * @param integer $id\r
+     * @return boolean\r
+     * @throws Zend_Search_Lucene_Exception    Exception is thrown if $id is out of the range\r
+     */\r
+    public function isDeleted($id)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+               $indexCount = $index->count();\r
+\r
+               if ($indexCount > $id) {\r
+               return $index->isDeleted($id);\r
+            }\r
+\r
+            $id -= $indexCount;\r
+        }\r
+\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('Document id is out of the range.');\r
+    }\r
+\r
+    /**\r
+     * Set default search field.\r
+     *\r
+     * Null means, that search is performed through all fields by default\r
+     *\r
+     * Default value is null\r
+     *\r
+     * @param string $fieldName\r
+     */\r
+    public static function setDefaultSearchField($fieldName)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+               $index->setDefaultSearchField($fieldName);\r
+        }\r
+    }\r
+\r
+\r
+    /**\r
+     * Get default search field.\r
+     *\r
+     * Null means, that search is performed through all fields by default\r
+     *\r
+     * @return string\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public static function getDefaultSearchField()\r
+    {\r
+       if (count($this->_indices) == 0) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Indices list is empty');\r
+       }\r
+\r
+       $defaultSearchField = reset($this->_indices)->getDefaultSearchField();\r
+\r
+       foreach ($this->_indices as $index) {\r
+               if ($index->getDefaultSearchField() !== $defaultSearchField) {\r
+                require_once 'Zend/Search/Lucene/Exception.php';\r
+                throw new Zend_Search_Lucene_Exception('Indices have different default search field.');\r
+               }\r
+       }\r
+\r
+       return $defaultSearchField;\r
+    }\r
+\r
+    /**\r
+     * Set result set limit.\r
+     *\r
+     * 0 (default) means no limit\r
+     *\r
+     * @param integer $limit\r
+     */\r
+    public static function setResultSetLimit($limit)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+            $index->setResultSetLimit($limit);\r
+        }\r
+    }\r
+\r
+    /**\r
+     * Set result set limit.\r
+     *\r
+     * 0 means no limit\r
+     *\r
+     * @return integer\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public static function getResultSetLimit()\r
+    {\r
+        if (count($this->_indices) == 0) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Indices list is empty');\r
+        }\r
+\r
+        $defaultResultSetLimit = reset($this->_indices)->getResultSetLimit();\r
+\r
+        foreach ($this->_indices as $index) {\r
+            if ($index->getResultSetLimit() !== $defaultResultSetLimit) {\r
+                require_once 'Zend/Search/Lucene/Exception.php';\r
+                throw new Zend_Search_Lucene_Exception('Indices have different default search field.');\r
+            }\r
+        }\r
+\r
+        return $defaultResultSetLimit;\r
+    }\r
+\r
+    /**\r
+     * Retrieve index maxBufferedDocs option\r
+     *\r
+     * maxBufferedDocs is a minimal number of documents required before\r
+     * the buffered in-memory documents are written into a new Segment\r
+     *\r
+     * Default value is 10\r
+     *\r
+     * @return integer\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function getMaxBufferedDocs()\r
+    {\r
+        if (count($this->_indices) == 0) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Indices list is empty');\r
+        }\r
+\r
+        $maxBufferedDocs = reset($this->_indices)->getMaxBufferedDocs();\r
+\r
+        foreach ($this->_indices as $index) {\r
+            if ($index->getMaxBufferedDocs() !== $maxBufferedDocs) {\r
+                require_once 'Zend/Search/Lucene/Exception.php';\r
+                throw new Zend_Search_Lucene_Exception('Indices have different default search field.');\r
+            }\r
+        }\r
+\r
+        return $maxBufferedDocs;\r
+    }\r
+\r
+    /**\r
+     * Set index maxBufferedDocs option\r
+     *\r
+     * maxBufferedDocs is a minimal number of documents required before\r
+     * the buffered in-memory documents are written into a new Segment\r
+     *\r
+     * Default value is 10\r
+     *\r
+     * @param integer $maxBufferedDocs\r
+     */\r
+    public function setMaxBufferedDocs($maxBufferedDocs)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+            $index->setMaxBufferedDocs($maxBufferedDocs);\r
+        }\r
+    }\r
+\r
+    /**\r
+     * Retrieve index maxMergeDocs option\r
+     *\r
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().\r
+     * Small values (e.g., less than 10,000) are best for interactive indexing,\r
+     * as this limits the length of pauses while indexing to a few seconds.\r
+     * Larger values are best for batched indexing and speedier searches.\r
+     *\r
+     * Default value is PHP_INT_MAX\r
+     *\r
+     * @return integer\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function getMaxMergeDocs()\r
+    {\r
+        if (count($this->_indices) == 0) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Indices list is empty');\r
+        }\r
+\r
+        $maxMergeDocs = reset($this->_indices)->getMaxMergeDocs();\r
+\r
+        foreach ($this->_indices as $index) {\r
+            if ($index->getMaxMergeDocs() !== $maxMergeDocs) {\r
+                require_once 'Zend/Search/Lucene/Exception.php';\r
+                throw new Zend_Search_Lucene_Exception('Indices have different default search field.');\r
+            }\r
+        }\r
+\r
+        return $maxMergeDocs;\r
+    }\r
+\r
+    /**\r
+     * Set index maxMergeDocs option\r
+     *\r
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().\r
+     * Small values (e.g., less than 10,000) are best for interactive indexing,\r
+     * as this limits the length of pauses while indexing to a few seconds.\r
+     * Larger values are best for batched indexing and speedier searches.\r
+     *\r
+     * Default value is PHP_INT_MAX\r
+     *\r
+     * @param integer $maxMergeDocs\r
+     */\r
+    public function setMaxMergeDocs($maxMergeDocs)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+            $index->setMaxMergeDocs($maxMergeDocs);\r
+        }\r
+    }\r
+\r
+    /**\r
+     * Retrieve index mergeFactor option\r
+     *\r
+     * mergeFactor determines how often segment indices are merged by addDocument().\r
+     * With smaller values, less RAM is used while indexing,\r
+     * and searches on unoptimized indices are faster,\r
+     * but indexing speed is slower.\r
+     * With larger values, more RAM is used during indexing,\r
+     * and while searches on unoptimized indices are slower,\r
+     * indexing is faster.\r
+     * Thus larger values (> 10) are best for batch index creation,\r
+     * and smaller values (< 10) for indices that are interactively maintained.\r
+     *\r
+     * Default value is 10\r
+     *\r
+     * @return integer\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function getMergeFactor()\r
+    {\r
+        if (count($this->_indices) == 0) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Indices list is empty');\r
+        }\r
+\r
+        $mergeFactor = reset($this->_indices)->getMergeFactor();\r
+\r
+        foreach ($this->_indices as $index) {\r
+            if ($index->getMergeFactor() !== $mergeFactor) {\r
+                require_once 'Zend/Search/Lucene/Exception.php';\r
+                throw new Zend_Search_Lucene_Exception('Indices have different default search field.');\r
+            }\r
+        }\r
+\r
+        return $mergeFactor;\r
+    }\r
+\r
+    /**\r
+     * Set index mergeFactor option\r
+     *\r
+     * mergeFactor determines how often segment indices are merged by addDocument().\r
+     * With smaller values, less RAM is used while indexing,\r
+     * and searches on unoptimized indices are faster,\r
+     * but indexing speed is slower.\r
+     * With larger values, more RAM is used during indexing,\r
+     * and while searches on unoptimized indices are slower,\r
+     * indexing is faster.\r
+     * Thus larger values (> 10) are best for batch index creation,\r
+     * and smaller values (< 10) for indices that are interactively maintained.\r
+     *\r
+     * Default value is 10\r
+     *\r
+     * @param integer $maxMergeDocs\r
+     */\r
+    public function setMergeFactor($mergeFactor)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+            $index->setMaxMergeDocs($maxMergeDocs);\r
+        }\r
+    }\r
+\r
+    /**\r
+     * Performs a query against the index and returns an array\r
+     * of Zend_Search_Lucene_Search_QueryHit objects.\r
+     * Input is a string or Zend_Search_Lucene_Search_Query.\r
+     *\r
+     * @param mixed $query\r
+     * @return array Zend_Search_Lucene_Search_QueryHit\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function find($query)\r
+    {\r
+       $hitsList = array();\r
+\r
+       $indexShift = 0;\r
+       foreach ($this->_indices as $index) {\r
+               $hits = $index->find($query);\r
+\r
+               if ($indexShift != 0) {\r
+                foreach ($hits as $hit) {\r
+                    $hit->id += $indexShift;\r
+                }\r
+               }\r
+\r
+               $indexShift += $index->count();\r
+               $hitsList[] = $hits;\r
+       }\r
+\r
+       /** @todo Implement advanced sorting */\r
+\r
+       return call_user_func_array('array_merge', $hitsList);\r
+    }\r
+\r
+    /**\r
+     * Returns a list of all unique field names that exist in this index.\r
+     *\r
+     * @param boolean $indexed\r
+     * @return array\r
+     */\r
+    public function getFieldNames($indexed = false)\r
+    {\r
+       $fieldNamesList = array();\r
+\r
+       foreach ($this->_indices as $index) {\r
+               $fieldNamesList[] = $index->getFieldNames($indexed);\r
+       }\r
+\r
+       return array_unique(call_user_func_array('array_merge', $fieldNamesList));\r
+    }\r
+\r
+    /**\r
+     * Returns a Zend_Search_Lucene_Document object for the document\r
+     * number $id in this index.\r
+     *\r
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id\r
+     * @return Zend_Search_Lucene_Document\r
+     * @throws Zend_Search_Lucene_Exception    Exception is thrown if $id is out of the range\r
+     */\r
+    public function getDocument($id)\r
+    {\r
+        if ($id instanceof Zend_Search_Lucene_Search_QueryHit) {\r
+            /* @var $id Zend_Search_Lucene_Search_QueryHit */\r
+            $id = $id->id;\r
+        }\r
+\r
+       foreach ($this->_indices as $index) {\r
+            $indexCount = $index->count();\r
+\r
+            if ($indexCount > $id) {\r
+                return $index->getDocument($id);\r
+            }\r
+\r
+            $id -= $indexCount;\r
+        }\r
+\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('Document id is out of the range.');\r
+    }\r
+\r
+    /**\r
+     * Returns true if index contain documents with specified term.\r
+     *\r
+     * Is used for query optimization.\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $term\r
+     * @return boolean\r
+     */\r
+    public function hasTerm(Zend_Search_Lucene_Index_Term $term)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+               if ($index->hasTerm($term)) {\r
+                       return true;\r
+               }\r
+        }\r
+\r
+        return false;\r
+    }\r
+\r
+    /**\r
+     * Returns IDs of all the documents containing term.\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $term\r
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter\r
+     * @return array\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function termDocs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)\r
+    {\r
+       if ($docsFilter != null) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher');\r
+       }\r
+\r
+        $docsList = array();\r
+\r
+        $indexShift = 0;\r
+        foreach ($this->_indices as $index) {\r
+            $docs = $index->termDocs($term);\r
+\r
+            if ($indexShift != 0) {\r
+                foreach ($docs as $id => $docId) {\r
+                    $docs[$id] += $indexShift;\r
+                }\r
+            }\r
+\r
+            $indexShift += $index->count();\r
+            $docsList[] = $docs;\r
+        }\r
+\r
+        return call_user_func_array('array_merge', $docsList);\r
+    }\r
+\r
+    /**\r
+     * Returns documents filter for all documents containing term.\r
+     *\r
+     * It performs the same operation as termDocs, but return result as\r
+     * Zend_Search_Lucene_Index_DocsFilter object\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $term\r
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter\r
+     * @return Zend_Search_Lucene_Index_DocsFilter\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function termDocsFilter(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher');\r
+    }\r
+\r
+    /**\r
+     * Returns an array of all term freqs.\r
+     * Return array structure: array( docId => freq, ...)\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $term\r
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter\r
+     * @return integer\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function termFreqs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)\r
+    {\r
+        if ($docsFilter != null) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher');\r
+        }\r
+\r
+        $freqsList = array();\r
+\r
+        $indexShift = 0;\r
+        foreach ($this->_indices as $index) {\r
+            $freqs = $index->termFreqs($term);\r
+\r
+            if ($indexShift != 0) {\r
+               $freqsShifted = array();\r
+\r
+                foreach ($freqs as $docId => $freq) {\r
+                       $freqsShifted[$docId + $indexShift] = $freq;\r
+                }\r
+                $freqs = $freqsShifted;\r
+            }\r
+\r
+            $indexShift += $index->count();\r
+            $freqsList[] = $freqs;\r
+        }\r
+\r
+        return call_user_func_array('array_merge', $freqsList);\r
+    }\r
+\r
+    /**\r
+     * Returns an array of all term positions in the documents.\r
+     * Return array structure: array( docId => array( pos1, pos2, ...), ...)\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $term\r
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter\r
+     * @return array\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function termPositions(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)\r
+    {\r
+        if ($docsFilter != null) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher');\r
+        }\r
+\r
+        $termPositionsList = array();\r
+\r
+        $indexShift = 0;\r
+        foreach ($this->_indices as $index) {\r
+            $termPositions = $index->termPositions($term);\r
+\r
+            if ($indexShift != 0) {\r
+                $termPositionsShifted = array();\r
+\r
+                foreach ($termPositions as $docId => $positions) {\r
+                    $termPositions[$docId + $indexShift] = $positions;\r
+                }\r
+                $termPositions = $termPositionsShifted;\r
+            }\r
+\r
+            $indexShift += $index->count();\r
+            $termPositionsList[] = $termPositions;\r
+        }\r
+\r
+        return call_user_func_array('array_merge', $termPositions);\r
+    }\r
+\r
+    /**\r
+     * Returns the number of documents in this index containing the $term.\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $term\r
+     * @return integer\r
+     */\r
+    public function docFreq(Zend_Search_Lucene_Index_Term $term)\r
+    {\r
+       $docFreq = 0;\r
+\r
+       foreach ($this->_indices as $index) {\r
+               $docFreq += $index->docFreq($term);\r
+       }\r
+\r
+       return $docFreq;\r
+    }\r
+\r
+    /**\r
+     * Retrive similarity used by index reader\r
+     *\r
+     * @return Zend_Search_Lucene_Search_Similarity\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function getSimilarity()\r
+    {\r
+        if (count($this->_indices) == 0) {\r
+            require_once 'Zend/Search/Lucene/Exception.php';\r
+            throw new Zend_Search_Lucene_Exception('Indices list is empty');\r
+        }\r
+\r
+        $similarity = reset($this->_indices)->getSimilarity();\r
+\r
+        foreach ($this->_indices as $index) {\r
+            if ($index->getSimilarity() !== $similarity) {\r
+                require_once 'Zend/Search/Lucene/Exception.php';\r
+                throw new Zend_Search_Lucene_Exception('Indices have different similarity.');\r
+            }\r
+        }\r
+\r
+        return $similarity;\r
+    }\r
+\r
+    /**\r
+     * Returns a normalization factor for "field, document" pair.\r
+     *\r
+     * @param integer $id\r
+     * @param string $fieldName\r
+     * @return float\r
+     */\r
+    public function norm($id, $fieldName)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+            $indexCount = $index->count();\r
+\r
+            if ($indexCount > $id) {\r
+                return $index->norm($id, $fieldName);\r
+            }\r
+\r
+            $id -= $indexCount;\r
+        }\r
+\r
+        return null;\r
+    }\r
+\r
+    /**\r
+     * Returns true if any documents have been deleted from this index.\r
+     *\r
+     * @return boolean\r
+     */\r
+    public function hasDeletions()\r
+    {\r
+       foreach ($this->_indices as $index) {\r
+               if ($index->hasDeletions()) {\r
+                       return true;\r
+               }\r
+       }\r
+\r
+       return false;\r
+    }\r
+\r
+    /**\r
+     * Deletes a document from the index.\r
+     * $id is an internal document id\r
+     *\r
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function delete($id)\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+            $indexCount = $index->count();\r
+\r
+            if ($indexCount > $id) {\r
+                $index->delete($id);\r
+                return;\r
+            }\r
+\r
+            $id -= $indexCount;\r
+        }\r
+\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('Document id is out of the range.');\r
+    }\r
+\r
+\r
+    /**\r
+     * Callback used to choose target index for new documents\r
+     *\r
+     * Function/method signature:\r
+     *    Zend_Search_Lucene_Interface  callbackFunction(Zend_Search_Lucene_Document $document, array $indices);\r
+     *\r
+     * null means "default documents distributing algorithm"\r
+     *\r
+     * @var callback\r
+     */\r
+    protected $_documentDistributorCallBack = null;\r
+\r
+    /**\r
+     * Set callback for choosing target index.\r
+     *\r
+     * @param callback $callback\r
+     */\r
+    public function setDocumentDistributorCallback($callback)\r
+    {\r
+       if ($callback !== null  &&  !is_callable($callback))\r
+       $this->_documentDistributorCallBack = $callback;\r
+    }\r
+\r
+    /**\r
+     * Get callback for choosing target index.\r
+     *\r
+     * @return callback\r
+     */\r
+    public function getDocumentDistributorCallback()\r
+    {\r
+        return $this->_documentDistributorCallBack;\r
+    }\r
+\r
+    /**\r
+     * Adds a document to this index.\r
+     *\r
+     * @param Zend_Search_Lucene_Document $document\r
+     * @throws Zend_Search_Lucene_Exception\r
+     */\r
+    public function addDocument(Zend_Search_Lucene_Document $document)\r
+    {\r
+       if ($this->_documentDistributorCallBack !== null) {\r
+               $index = call_user_func($this->_documentDistributorCallBack, $document, $this->_indices);\r
+       } else {\r
+               $index = $this->_indices[ array_rand($this->_indices) ];\r
+       }\r
+\r
+       $index->addDocument($document);\r
+    }\r
+\r
+    /**\r
+     * Commit changes resulting from delete() or undeleteAll() operations.\r
+     */\r
+    public function commit()\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+               $index->commit();\r
+        }\r
+    }\r
+\r
+    /**\r
+     * Optimize index.\r
+     *\r
+     * Merges all segments into one\r
+     */\r
+    public function optimize()\r
+    {\r
+       foreach ($this->_indices as $index) {\r
+               $index->_optimise();\r
+       }\r
+    }\r
+\r
+    /**\r
+     * Returns an array of all terms in this index.\r
+     *\r
+     * @return array\r
+     */\r
+    public function terms()\r
+    {\r
+       $termsList = array();\r
+\r
+       foreach ($this->_indices as $index) {\r
+               $termsList[] = $index->terms();\r
+       }\r
+\r
+       return array_unique(call_user_func_array('array_merge', $termsList));\r
+    }\r
+\r
+\r
+    /**\r
+     * Terms stream priority queue object\r
+     *\r
+     * @var Zend_Search_Lucene_TermStreamsPriorityQueue\r
+     */\r
+    private $_termsStream = null;\r
+\r
+    /**\r
+     * Reset terms stream.\r
+     */\r
+    public function resetTermsStream()\r
+    {\r
+        if ($this->_termsStream === null) {\r
+            $this->_termsStream = new Zend_Search_Lucene_TermStreamsPriorityQueue($this->_indices);\r
+        } else {\r
+            $this->_termsStream->resetTermsStream();\r
+        }\r
+    }\r
+\r
+    /**\r
+     * Skip terms stream up to specified term preffix.\r
+     *\r
+     * Prefix contains fully specified field info and portion of searched term\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $prefix\r
+     */\r
+    public function skipTo(Zend_Search_Lucene_Index_Term $prefix)\r
+    {\r
+        $this->_termsStream->skipTo($prefix);\r
+    }\r
+\r
+    /**\r
+     * Scans terms dictionary and returns next term\r
+     *\r
+     * @return Zend_Search_Lucene_Index_Term|null\r
+     */\r
+    public function nextTerm()\r
+    {\r
+        return $this->_termsStream->nextTerm();\r
+    }\r
+\r
+    /**\r
+     * Returns term in current position\r
+     *\r
+     * @return Zend_Search_Lucene_Index_Term|null\r
+     */\r
+    public function currentTerm()\r
+    {\r
+        return $this->_termsStream->currentTerm();\r
+    }\r
+\r
+    /**\r
+     * Close terms stream\r
+     *\r
+     * Should be used for resources clean up if stream is not read up to the end\r
+     */\r
+    public function closeTermsStream()\r
+    {\r
+        $this->_termsStream->closeTermsStream();\r
+        $this->_termsStream = null;\r
+    }\r
+\r
+\r
+    /**\r
+     * Undeletes all documents currently marked as deleted in this index.\r
+     */\r
+    public function undeleteAll()\r
+    {\r
+        foreach ($this->_indices as $index) {\r
+            $index->undeleteAll();\r
+        }\r
+    }\r
+\r
+\r
+    /**\r
+     * Add reference to the index object\r
+     *\r
+     * @internal\r
+     */\r
+    public function addReference()\r
+    {\r
+       // Do nothing, since it's never referenced by indices\r
+    }\r
+\r
+    /**\r
+     * Remove reference from the index object\r
+     *\r
+     * When reference count becomes zero, index is closed and resources are cleaned up\r
+     *\r
+     * @internal\r
+     */\r
+    public function removeReference()\r
+    {\r
+       // Do nothing, since it's never referenced by indices\r
+    }\r
+}\r
diff --git a/inc/lib/Zend/Search/Lucene/PriorityQueue.php b/inc/lib/Zend/Search/Lucene/PriorityQueue.php
new file mode 100644 (file)
index 0000000..5de3e53
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: PriorityQueue.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * Abstract Priority Queue
+ *
+ * It implements a priority queue.
+ * Please go to "Data Structures and Algorithms",
+ * Aho, Hopcroft, and Ullman, Addison-Wesley, 1983 (corrected 1987 edition),
+ * for implementation details.
+ *
+ * It provides O(log(N)) time of put/pop operations, where N is a size of queue
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_PriorityQueue
+{
+    /**
+     * Queue heap
+     *
+     * Heap contains balanced partial ordered binary tree represented in array
+     * [0] - top of the tree
+     * [1] - first child of [0]
+     * [2] - second child of [0]
+     * ...
+     * [2*n + 1] - first child of [n]
+     * [2*n + 2] - second child of [n]
+     *
+     * @var array
+     */
+    private $_heap = array();
+
+
+    /**
+     * Add element to the queue
+     *
+     * O(log(N)) time
+     *
+     * @param mixed $element
+     */
+    public function put($element)
+    {
+        $nodeId   = count($this->_heap);
+        $parentId = ($nodeId-1) >> 1;   // floor( ($nodeId-1)/2 )
+
+        while ($nodeId != 0  &&  $this->_less($element, $this->_heap[$parentId])) {
+            // Move parent node down
+            $this->_heap[$nodeId] = $this->_heap[$parentId];
+
+            // Move pointer to the next level of tree
+            $nodeId   = $parentId;
+            $parentId = ($nodeId-1) >> 1;   // floor( ($nodeId-1)/2 )
+        }
+
+        // Put new node into the tree
+        $this->_heap[$nodeId] = $element;
+    }
+
+
+    /**
+     * Return least element of the queue
+     *
+     * Constant time
+     *
+     * @return mixed
+     */
+    public function top()
+    {
+        if (count($this->_heap) == 0) {
+            return null;
+        }
+
+        return $this->_heap[0];
+    }
+
+
+    /**
+     * Removes and return least element of the queue
+     *
+     * O(log(N)) time
+     *
+     * @return mixed
+     */
+    public function pop()
+    {
+        if (count($this->_heap) == 0) {
+            return null;
+        }
+
+        $top = $this->_heap[0];
+        $lastId = count($this->_heap) - 1;
+
+        /**
+         * Find appropriate position for last node
+         */
+        $nodeId  = 0;     // Start from a top
+        $childId = 1;     // First child
+
+        // Choose smaller child
+        if ($lastId > 2  &&  $this->_less($this->_heap[2], $this->_heap[1])) {
+            $childId = 2;
+        }
+
+        while ($childId < $lastId  &&
+               $this->_less($this->_heap[$childId], $this->_heap[$lastId])
+          ) {
+            // Move child node up
+            $this->_heap[$nodeId] = $this->_heap[$childId];
+
+            $nodeId  = $childId;               // Go down
+            $childId = ($nodeId << 1) + 1;     // First child
+
+            // Choose smaller child
+            if (($childId+1) < $lastId  &&
+                $this->_less($this->_heap[$childId+1], $this->_heap[$childId])
+               ) {
+                $childId++;
+            }
+        }
+
+        // Move last element to the new position
+        $this->_heap[$nodeId] = $this->_heap[$lastId];
+        unset($this->_heap[$lastId]);
+
+        return $top;
+    }
+
+
+    /**
+     * Clear queue
+     */
+    public function clear()
+    {
+        $this->_heap = array();
+    }
+
+
+    /**
+     * Compare elements
+     *
+     * Returns true, if $el1 is less than $el2; else otherwise
+     *
+     * @param mixed $el1
+     * @param mixed $el2
+     * @return boolean
+     */
+    abstract protected function _less($el1, $el2);
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Proxy.php b/inc/lib/Zend/Search/Lucene/Proxy.php
new file mode 100644 (file)
index 0000000..5164968
--- /dev/null
@@ -0,0 +1,612 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Proxy.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Interface */
+require_once 'Zend/Search/Lucene/Interface.php';
+
+
+/**
+ * Proxy class intended to be used in userland.
+ *
+ * It tracks, when index object goes out of scope and forces ndex closing
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Proxy implements Zend_Search_Lucene_Interface
+{
+    /**
+     * Index object
+     *
+     * @var Zend_Search_Lucene_Interface
+     */
+    private $_index;
+
+    /**
+     * Object constructor
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     */
+    public function __construct(Zend_Search_Lucene_Interface $index)
+    {
+        $this->_index = $index;
+        $this->_index->addReference();
+    }
+
+    /**
+     * Object destructor
+     */
+    public function __destruct()
+    {
+        if ($this->_index !== null) {
+            // This code is invoked if Zend_Search_Lucene_Interface object constructor throws an exception
+            $this->_index->removeReference();
+        }
+        $this->_index = null;
+    }
+
+    /**
+     * Get current generation number
+     *
+     * Returns generation number
+     * 0 means pre-2.1 index format
+     * -1 means there are no segments files.
+     *
+     * @param Zend_Search_Lucene_Storage_Directory $directory
+     * @return integer
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public static function getActualGeneration(Zend_Search_Lucene_Storage_Directory $directory)
+    {
+        Zend_Search_Lucene::getActualGeneration($directory);
+    }
+
+    /**
+     * Get segments file name
+     *
+     * @param integer $generation
+     * @return string
+     */
+    public static function getSegmentFileName($generation)
+    {
+        Zend_Search_Lucene::getSegmentFileName($generation);
+    }
+
+    /**
+     * Get index format version
+     *
+     * @return integer
+     */
+    public function getFormatVersion()
+    {
+        return $this->_index->getFormatVersion();
+    }
+
+    /**
+     * Set index format version.
+     * Index is converted to this format at the nearest upfdate time
+     *
+     * @param int $formatVersion
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function setFormatVersion($formatVersion)
+    {
+        $this->_index->setFormatVersion($formatVersion);
+    }
+
+    /**
+     * Returns the Zend_Search_Lucene_Storage_Directory instance for this index.
+     *
+     * @return Zend_Search_Lucene_Storage_Directory
+     */
+    public function getDirectory()
+    {
+        return $this->_index->getDirectory();
+    }
+
+    /**
+     * Returns the total number of documents in this index (including deleted documents).
+     *
+     * @return integer
+     */
+    public function count()
+    {
+        return $this->_index->count();
+    }
+
+    /**
+     * Returns one greater than the largest possible document number.
+     * This may be used to, e.g., determine how big to allocate a structure which will have
+     * an element for every document number in an index.
+     *
+     * @return integer
+     */
+    public function maxDoc()
+    {
+        return $this->_index->maxDoc();
+    }
+
+    /**
+     * Returns the total number of non-deleted documents in this index.
+     *
+     * @return integer
+     */
+    public function numDocs()
+    {
+        return $this->_index->numDocs();
+    }
+
+    /**
+     * Checks, that document is deleted
+     *
+     * @param integer $id
+     * @return boolean
+     * @throws Zend_Search_Lucene_Exception    Exception is thrown if $id is out of the range
+     */
+    public function isDeleted($id)
+    {
+        return $this->_index->isDeleted($id);
+    }
+
+    /**
+     * Set default search field.
+     *
+     * Null means, that search is performed through all fields by default
+     *
+     * Default value is null
+     *
+     * @param string $fieldName
+     */
+    public static function setDefaultSearchField($fieldName)
+    {
+        Zend_Search_Lucene::setDefaultSearchField($fieldName);
+    }
+
+    /**
+     * Get default search field.
+     *
+     * Null means, that search is performed through all fields by default
+     *
+     * @return string
+     */
+    public static function getDefaultSearchField()
+    {
+        return Zend_Search_Lucene::getDefaultSearchField();
+    }
+
+    /**
+     * Set result set limit.
+     *
+     * 0 (default) means no limit
+     *
+     * @param integer $limit
+     */
+    public static function setResultSetLimit($limit)
+    {
+        Zend_Search_Lucene::setResultSetLimit($limit);
+    }
+
+    /**
+     * Set result set limit.
+     *
+     * 0 means no limit
+     *
+     * @return integer
+     */
+    public static function getResultSetLimit()
+    {
+        return Zend_Search_Lucene::getResultSetLimit();
+    }
+
+    /**
+     * Retrieve index maxBufferedDocs option
+     *
+     * maxBufferedDocs is a minimal number of documents required before
+     * the buffered in-memory documents are written into a new Segment
+     *
+     * Default value is 10
+     *
+     * @return integer
+     */
+    public function getMaxBufferedDocs()
+    {
+        return $this->_index->getMaxBufferedDocs();
+    }
+
+    /**
+     * Set index maxBufferedDocs option
+     *
+     * maxBufferedDocs is a minimal number of documents required before
+     * the buffered in-memory documents are written into a new Segment
+     *
+     * Default value is 10
+     *
+     * @param integer $maxBufferedDocs
+     */
+    public function setMaxBufferedDocs($maxBufferedDocs)
+    {
+        $this->_index->setMaxBufferedDocs($maxBufferedDocs);
+    }
+
+
+    /**
+     * Retrieve index maxMergeDocs option
+     *
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().
+     * Small values (e.g., less than 10,000) are best for interactive indexing,
+     * as this limits the length of pauses while indexing to a few seconds.
+     * Larger values are best for batched indexing and speedier searches.
+     *
+     * Default value is PHP_INT_MAX
+     *
+     * @return integer
+     */
+    public function getMaxMergeDocs()
+    {
+        return $this->_index->getMaxMergeDocs();
+    }
+
+    /**
+     * Set index maxMergeDocs option
+     *
+     * maxMergeDocs is a largest number of documents ever merged by addDocument().
+     * Small values (e.g., less than 10,000) are best for interactive indexing,
+     * as this limits the length of pauses while indexing to a few seconds.
+     * Larger values are best for batched indexing and speedier searches.
+     *
+     * Default value is PHP_INT_MAX
+     *
+     * @param integer $maxMergeDocs
+     */
+    public function setMaxMergeDocs($maxMergeDocs)
+    {
+        $this->_index->setMaxMergeDocs($maxMergeDocs);
+    }
+
+
+    /**
+     * Retrieve index mergeFactor option
+     *
+     * mergeFactor determines how often segment indices are merged by addDocument().
+     * With smaller values, less RAM is used while indexing,
+     * and searches on unoptimized indices are faster,
+     * but indexing speed is slower.
+     * With larger values, more RAM is used during indexing,
+     * and while searches on unoptimized indices are slower,
+     * indexing is faster.
+     * Thus larger values (> 10) are best for batch index creation,
+     * and smaller values (< 10) for indices that are interactively maintained.
+     *
+     * Default value is 10
+     *
+     * @return integer
+     */
+    public function getMergeFactor()
+    {
+        return $this->_index->getMergeFactor();
+    }
+
+    /**
+     * Set index mergeFactor option
+     *
+     * mergeFactor determines how often segment indices are merged by addDocument().
+     * With smaller values, less RAM is used while indexing,
+     * and searches on unoptimized indices are faster,
+     * but indexing speed is slower.
+     * With larger values, more RAM is used during indexing,
+     * and while searches on unoptimized indices are slower,
+     * indexing is faster.
+     * Thus larger values (> 10) are best for batch index creation,
+     * and smaller values (< 10) for indices that are interactively maintained.
+     *
+     * Default value is 10
+     *
+     * @param integer $maxMergeDocs
+     */
+    public function setMergeFactor($mergeFactor)
+    {
+        $this->_index->setMergeFactor($mergeFactor);
+    }
+
+    /**
+     * Performs a query against the index and returns an array
+     * of Zend_Search_Lucene_Search_QueryHit objects.
+     * Input is a string or Zend_Search_Lucene_Search_Query.
+     *
+     * @param mixed $query
+     * @return array Zend_Search_Lucene_Search_QueryHit
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function find($query)
+    {
+        // actual parameter list
+        $parameters = func_get_args();
+
+        // invoke $this->_index->find() method with specified parameters
+        return call_user_func_array(array(&$this->_index, 'find'), $parameters);
+    }
+
+    /**
+     * Returns a list of all unique field names that exist in this index.
+     *
+     * @param boolean $indexed
+     * @return array
+     */
+    public function getFieldNames($indexed = false)
+    {
+        return $this->_index->getFieldNames($indexed);
+    }
+
+    /**
+     * Returns a Zend_Search_Lucene_Document object for the document
+     * number $id in this index.
+     *
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id
+     * @return Zend_Search_Lucene_Document
+     */
+    public function getDocument($id)
+    {
+        return $this->_index->getDocument($id);
+    }
+
+    /**
+     * Returns true if index contain documents with specified term.
+     *
+     * Is used for query optimization.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @return boolean
+     */
+    public function hasTerm(Zend_Search_Lucene_Index_Term $term)
+    {
+        return $this->_index->hasTerm($term);
+    }
+
+    /**
+     * Returns IDs of all the documents containing term.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return array
+     */
+    public function termDocs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        return $this->_index->termDocs($term, $docsFilter);
+    }
+
+    /**
+     * Returns documents filter for all documents containing term.
+     *
+     * It performs the same operation as termDocs, but return result as
+     * Zend_Search_Lucene_Index_DocsFilter object
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return Zend_Search_Lucene_Index_DocsFilter
+     */
+    public function termDocsFilter(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        return $this->_index->termDocsFilter($term, $docsFilter);
+    }
+
+    /**
+     * Returns an array of all term freqs.
+     * Return array structure: array( docId => freq, ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return integer
+     */
+    public function termFreqs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        return $this->_index->termFreqs($term, $docsFilter);
+    }
+
+    /**
+     * Returns an array of all term positions in the documents.
+     * Return array structure: array( docId => array( pos1, pos2, ...), ...)
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @return array
+     */
+    public function termPositions(Zend_Search_Lucene_Index_Term $term, $docsFilter = null)
+    {
+        return $this->_index->termPositions($term, $docsFilter);
+    }
+
+    /**
+     * Returns the number of documents in this index containing the $term.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @return integer
+     */
+    public function docFreq(Zend_Search_Lucene_Index_Term $term)
+    {
+        return $this->_index->docFreq($term);
+    }
+
+    /**
+     * Retrive similarity used by index reader
+     *
+     * @return Zend_Search_Lucene_Search_Similarity
+     */
+    public function getSimilarity()
+    {
+        return $this->_index->getSimilarity();
+    }
+
+    /**
+     * Returns a normalization factor for "field, document" pair.
+     *
+     * @param integer $id
+     * @param string $fieldName
+     * @return float
+     */
+    public function norm($id, $fieldName)
+    {
+        return $this->_index->norm($id, $fieldName);
+    }
+
+    /**
+     * Returns true if any documents have been deleted from this index.
+     *
+     * @return boolean
+     */
+    public function hasDeletions()
+    {
+        return $this->_index->hasDeletions();
+    }
+
+    /**
+     * Deletes a document from the index.
+     * $id is an internal document id
+     *
+     * @param integer|Zend_Search_Lucene_Search_QueryHit $id
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function delete($id)
+    {
+        return $this->_index->delete($id);
+    }
+
+    /**
+     * Adds a document to this index.
+     *
+     * @param Zend_Search_Lucene_Document $document
+     */
+    public function addDocument(Zend_Search_Lucene_Document $document)
+    {
+        $this->_index->addDocument($document);
+    }
+
+    /**
+     * Commit changes resulting from delete() or undeleteAll() operations.
+     */
+    public function commit()
+    {
+        $this->_index->commit();
+    }
+
+    /**
+     * Optimize index.
+     *
+     * Merges all segments into one
+     */
+    public function optimize()
+    {
+        $this->_index->optimize();
+    }
+
+    /**
+     * Returns an array of all terms in this index.
+     *
+     * @return array
+     */
+    public function terms()
+    {
+        return $this->_index->terms();
+    }
+
+
+    /**
+     * Reset terms stream.
+     */
+    public function resetTermsStream()
+    {
+        $this->_index->resetTermsStream();
+    }
+
+    /**
+     * Skip terms stream up to specified term preffix.
+     *
+     * Prefix contains fully specified field info and portion of searched term
+     *
+     * @param Zend_Search_Lucene_Index_Term $prefix
+     */
+    public function skipTo(Zend_Search_Lucene_Index_Term $prefix)
+    {
+        return $this->_index->skipTo($prefix);
+    }
+
+    /**
+     * Scans terms dictionary and returns next term
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function nextTerm()
+    {
+        return $this->_index->nextTerm();
+    }
+
+    /**
+     * Returns term in current position
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function currentTerm()
+    {
+        return $this->_index->currentTerm();
+    }
+
+    /**
+     * Close terms stream
+     *
+     * Should be used for resources clean up if stream is not read up to the end
+     */
+    public function closeTermsStream()
+    {
+        $this->_index->closeTermsStream();
+    }
+
+
+    /**
+     * Undeletes all documents currently marked as deleted in this index.
+     */
+    public function undeleteAll()
+    {
+        return $this->_index->undeleteAll();
+    }
+
+    /**
+     * Add reference to the index object
+     *
+     * @internal
+     */
+    public function addReference()
+    {
+        return $this->_index->addReference();
+    }
+
+    /**
+     * Remove reference from the index object
+     *
+     * When reference count becomes zero, index is closed and resources are cleaned up
+     *
+     * @internal
+     */
+    public function removeReference()
+    {
+        return $this->_index->removeReference();
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php b/inc/lib/Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php
new file mode 100644 (file)
index 0000000..3fd4019
--- /dev/null
@@ -0,0 +1,278 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: BooleanExpressionRecognizer.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_FSM */
+require_once 'Zend/Search/Lucene/FSM.php';
+
+/** Zend_Search_Lucene_Search_QueryToken */
+require_once 'Zend/Search/Lucene/Search/QueryToken.php';
+
+/** Zend_Search_Lucene_Search_QueryParser */
+require_once 'Zend/Search/Lucene/Search/QueryParser.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_BooleanExpressionRecognizer extends Zend_Search_Lucene_FSM
+{
+    /** State Machine states */
+    const ST_START           = 0;
+    const ST_LITERAL         = 1;
+    const ST_NOT_OPERATOR    = 2;
+    const ST_AND_OPERATOR    = 3;
+    const ST_OR_OPERATOR     = 4;
+
+    /** Input symbols */
+    const IN_LITERAL         = 0;
+    const IN_NOT_OPERATOR    = 1;
+    const IN_AND_OPERATOR    = 2;
+    const IN_OR_OPERATOR     = 3;
+
+
+    /**
+     * NOT operator signal
+     *
+     * @var boolean
+     */
+    private $_negativeLiteral = false;
+
+    /**
+     * Current literal
+     *
+     * @var mixed
+     */
+    private $_literal;
+
+
+    /**
+     * Set of boolean query conjunctions
+     *
+     * Each conjunction is an array of conjunction elements
+     * Each conjunction element is presented with two-elements array:
+     * array(<literal>, <is_negative>)
+     *
+     * So, it has a structure:
+     * array( array( array(<literal>, <is_negative>), // first literal of first conjuction
+     *               array(<literal>, <is_negative>), // second literal of first conjuction
+     *               ...
+     *               array(<literal>, <is_negative>)
+     *             ), // end of first conjuction
+     *        array( array(<literal>, <is_negative>), // first literal of second conjuction
+     *               array(<literal>, <is_negative>), // second literal of second conjuction
+     *               ...
+     *               array(<literal>, <is_negative>)
+     *             ), // end of second conjuction
+     *        ...
+     *      ) // end of structure
+     *
+     * @var array
+     */
+    private $_conjunctions = array();
+
+    /**
+     * Current conjuction
+     *
+     * @var array
+     */
+    private $_currentConjunction = array();
+
+
+    /**
+     * Object constructor
+     */
+    public function __construct()
+    {
+        parent::__construct( array(self::ST_START,
+                                   self::ST_LITERAL,
+                                   self::ST_NOT_OPERATOR,
+                                   self::ST_AND_OPERATOR,
+                                   self::ST_OR_OPERATOR),
+                             array(self::IN_LITERAL,
+                                   self::IN_NOT_OPERATOR,
+                                   self::IN_AND_OPERATOR,
+                                   self::IN_OR_OPERATOR));
+
+        $emptyOperatorAction    = new Zend_Search_Lucene_FSMAction($this, 'emptyOperatorAction');
+        $emptyNotOperatorAction = new Zend_Search_Lucene_FSMAction($this, 'emptyNotOperatorAction');
+
+        $this->addRules(array( array(self::ST_START,        self::IN_LITERAL,        self::ST_LITERAL),
+                               array(self::ST_START,        self::IN_NOT_OPERATOR,   self::ST_NOT_OPERATOR),
+
+                               array(self::ST_LITERAL,      self::IN_AND_OPERATOR,   self::ST_AND_OPERATOR),
+                               array(self::ST_LITERAL,      self::IN_OR_OPERATOR,    self::ST_OR_OPERATOR),
+                               array(self::ST_LITERAL,      self::IN_LITERAL,        self::ST_LITERAL,      $emptyOperatorAction),
+                               array(self::ST_LITERAL,      self::IN_NOT_OPERATOR,   self::ST_NOT_OPERATOR, $emptyNotOperatorAction),
+
+                               array(self::ST_NOT_OPERATOR, self::IN_LITERAL,        self::ST_LITERAL),
+
+                               array(self::ST_AND_OPERATOR, self::IN_LITERAL,        self::ST_LITERAL),
+                               array(self::ST_AND_OPERATOR, self::IN_NOT_OPERATOR,   self::ST_NOT_OPERATOR),
+
+                               array(self::ST_OR_OPERATOR,  self::IN_LITERAL,        self::ST_LITERAL),
+                               array(self::ST_OR_OPERATOR,  self::IN_NOT_OPERATOR,   self::ST_NOT_OPERATOR),
+                             ));
+
+        $notOperatorAction     = new Zend_Search_Lucene_FSMAction($this, 'notOperatorAction');
+        $orOperatorAction      = new Zend_Search_Lucene_FSMAction($this, 'orOperatorAction');
+        $literalAction         = new Zend_Search_Lucene_FSMAction($this, 'literalAction');
+
+
+        $this->addEntryAction(self::ST_NOT_OPERATOR, $notOperatorAction);
+        $this->addEntryAction(self::ST_OR_OPERATOR,  $orOperatorAction);
+        $this->addEntryAction(self::ST_LITERAL,      $literalAction);
+    }
+
+
+    /**
+     * Process next operator.
+     *
+     * Operators are defined by class constants: IN_AND_OPERATOR, IN_OR_OPERATOR and IN_NOT_OPERATOR
+     *
+     * @param integer $operator
+     */
+    public function processOperator($operator)
+    {
+        $this->process($operator);
+    }
+
+    /**
+     * Process expression literal.
+     *
+     * @param integer $operator
+     */
+    public function processLiteral($literal)
+    {
+        $this->_literal = $literal;
+
+        $this->process(self::IN_LITERAL);
+    }
+
+    /**
+     * Finish an expression and return result
+     *
+     * Result is a set of boolean query conjunctions
+     *
+     * Each conjunction is an array of conjunction elements
+     * Each conjunction element is presented with two-elements array:
+     * array(<literal>, <is_negative>)
+     *
+     * So, it has a structure:
+     * array( array( array(<literal>, <is_negative>), // first literal of first conjuction
+     *               array(<literal>, <is_negative>), // second literal of first conjuction
+     *               ...
+     *               array(<literal>, <is_negative>)
+     *             ), // end of first conjuction
+     *        array( array(<literal>, <is_negative>), // first literal of second conjuction
+     *               array(<literal>, <is_negative>), // second literal of second conjuction
+     *               ...
+     *               array(<literal>, <is_negative>)
+     *             ), // end of second conjuction
+     *        ...
+     *      ) // end of structure
+     *
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function finishExpression()
+    {
+        if ($this->getState() != self::ST_LITERAL) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Literal expected.');
+        }
+
+        $this->_conjunctions[] = $this->_currentConjunction;
+
+        return $this->_conjunctions;
+    }
+
+
+
+    /*********************************************************************
+     * Actions implementation
+     *********************************************************************/
+
+    /**
+     * default (omitted) operator processing
+     */
+    public function emptyOperatorAction()
+    {
+        if (Zend_Search_Lucene_Search_QueryParser::getDefaultOperator() == Zend_Search_Lucene_Search_QueryParser::B_AND) {
+            // Do nothing
+        } else {
+            $this->orOperatorAction();
+        }
+
+        // Process literal
+        $this->literalAction();
+    }
+
+    /**
+     * default (omitted) + NOT operator processing
+     */
+    public function emptyNotOperatorAction()
+    {
+        if (Zend_Search_Lucene_Search_QueryParser::getDefaultOperator() == Zend_Search_Lucene_Search_QueryParser::B_AND) {
+            // Do nothing
+        } else {
+            $this->orOperatorAction();
+        }
+
+        // Process NOT operator
+        $this->notOperatorAction();
+    }
+
+
+    /**
+     * NOT operator processing
+     */
+    public function notOperatorAction()
+    {
+        $this->_negativeLiteral = true;
+    }
+
+    /**
+     * OR operator processing
+     * Close current conjunction
+     */
+    public function orOperatorAction()
+    {
+        $this->_conjunctions[]     = $this->_currentConjunction;
+        $this->_currentConjunction = array();
+    }
+
+    /**
+     * Literal processing
+     */
+    public function literalAction()
+    {
+        // Add literal to the current conjunction
+        $this->_currentConjunction[] = array($this->_literal, !$this->_negativeLiteral);
+
+        // Switch off negative signal
+        $this->_negativeLiteral = false;
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/Highlighter/Default.php b/inc/lib/Zend/Search/Lucene/Search/Highlighter/Default.php
new file mode 100644 (file)
index 0000000..ed59b35
--- /dev/null
@@ -0,0 +1,94 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: Default.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+/** Zend_Search_Lucene_Search_Highlighter_Interface */\r
+require_once 'Zend/Search/Lucene/Search/Highlighter/Interface.php';\r
+/**\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+class Zend_Search_Lucene_Search_Highlighter_Default implements Zend_Search_Lucene_Search_Highlighter_Interface\r
+{\r
+    /**\r
+     * List of colors for text highlighting\r
+     *\r
+     * @var array\r
+     */\r
+    protected $_highlightColors = array('#66ffff', '#ff66ff', '#ffff66',\r
+                                        '#ff8888', '#88ff88', '#8888ff',\r
+                                        '#88dddd', '#dd88dd', '#dddd88',\r
+                                        '#aaddff', '#aaffdd', '#ddaaff',\r
+                                        '#ddffaa', '#ffaadd', '#ffddaa');\r
+\r
+    /**\r
+     * Index of current color for highlighting\r
+     *\r
+     * Index is increased at each highlight() call, so terms matching different queries are highlighted using different colors.\r
+     *\r
+     * @var integer\r
+     */\r
+    protected $_currentColorIndex = 0;\r
+\r
+    /**\r
+     * HTML document for highlighting\r
+     *\r
+     * @var Zend_Search_Lucene_Document_Html\r
+     */\r
+    protected $_doc;\r
+\r
+    /**\r
+     * Set document for highlighting.\r
+     *\r
+     * @param Zend_Search_Lucene_Document_Html $document\r
+     */\r
+    public function setDocument(Zend_Search_Lucene_Document_Html $document)\r
+    {\r
+       $this->_doc = $document;\r
+    }\r
+\r
+    /**\r
+     * Get document for highlighting.\r
+     *\r
+     * @return Zend_Search_Lucene_Document_Html $document\r
+     */\r
+    public function getDocument()\r
+    {\r
+       return $this->_doc;\r
+    }\r
+\r
+    /**\r
+     * Highlight specified words\r
+     *\r
+     * @param string|array $words  Words to highlight. They could be organized using the array or string.\r
+     */\r
+    public function highlight($words)\r
+    {\r
+       $color = $this->_highlightColors[$this->_currentColorIndex];\r
+       $this->_currentColorIndex = ($this->_currentColorIndex + 1) % count($this->_highlightColors);\r
+\r
+       $this->_doc->highlight($words, $color);\r
+    }\r
+\r
+}\r
diff --git a/inc/lib/Zend/Search/Lucene/Search/Highlighter/Interface.php b/inc/lib/Zend/Search/Lucene/Search/Highlighter/Interface.php
new file mode 100644 (file)
index 0000000..bf13871
--- /dev/null
@@ -0,0 +1,53 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: Interface.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+\r
+/**\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+interface Zend_Search_Lucene_Search_Highlighter_Interface\r
+{\r
+       /**\r
+        * Set document for highlighting.\r
+        *\r
+        * @param Zend_Search_Lucene_Document_Html $document\r
+     */\r
+       public function setDocument(Zend_Search_Lucene_Document_Html $document);\r
+\r
+    /**\r
+     * Get document for highlighting.\r
+     *\r
+     * @return Zend_Search_Lucene_Document_Html $document\r
+     */\r
+    public function getDocument();\r
+\r
+    /**\r
+     * Highlight specified words (method is invoked once per subquery)\r
+     *\r
+     * @param string|array $words  Words to highlight. They could be organized using the array or string.\r
+     */\r
+    public function highlight($words);\r
+}\r
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query.php b/inc/lib/Zend/Search/Lucene/Search/Query.php
new file mode 100644 (file)
index 0000000..dbeab5b
--- /dev/null
@@ -0,0 +1,234 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Query.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Document_Html */
+require_once 'Zend/Search/Lucene/Document/Html.php';
+
+/** Zend_Search_Lucene_Index_DocsFilter */
+require_once 'Zend/Search/Lucene/Index/DocsFilter.php';
+
+/** Zend_Search_Lucene_Search_Highlighter_Default */
+require_once 'Zend/Search/Lucene/Search/Highlighter/Default.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Search_Query
+{
+    /**
+     * query boost factor
+     *
+     * @var float
+     */
+    private $_boost = 1;
+
+    /**
+     * Query weight
+     *
+     * @var Zend_Search_Lucene_Search_Weight
+     */
+    protected $_weight = null;
+
+    /**
+     * Current highlight color
+     *
+     * @var integer
+     */
+    private $_currentColorIndex = 0;
+
+    /**
+     * Gets the boost for this clause.  Documents matching
+     * this clause will (in addition to the normal weightings) have their score
+     * multiplied by boost.   The boost is 1.0 by default.
+     *
+     * @return float
+     */
+    public function getBoost()
+    {
+        return $this->_boost;
+    }
+
+    /**
+     * Sets the boost for this query clause to $boost.
+     *
+     * @param float $boost
+     */
+    public function setBoost($boost)
+    {
+        $this->_boost = $boost;
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    abstract public function score($docId, Zend_Search_Lucene_Interface $reader);
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     */
+    abstract public function matchedDocs();
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * Query specific implementation
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     */
+    abstract public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null);
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     */
+    abstract public function createWeight(Zend_Search_Lucene_Interface $reader);
+
+    /**
+     * Constructs an initializes a Weight for a _top-level_query_.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     */
+    protected function _initWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        // Check, that it's a top-level query and query weight is not initialized yet.
+        if ($this->_weight !== null) {
+            return $this->_weight;
+        }
+
+        $this->createWeight($reader);
+        $sum = $this->_weight->sumOfSquaredWeights();
+        $queryNorm = $reader->getSimilarity()->queryNorm($sum);
+        $this->_weight->normalize($queryNorm);
+    }
+
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    abstract public function rewrite(Zend_Search_Lucene_Interface $index);
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    abstract public function optimize(Zend_Search_Lucene_Interface $index);
+
+    /**
+     * Reset query, so it can be reused within other queries or
+     * with other indeces
+     */
+    public function reset()
+    {
+        $this->_weight = null;
+    }
+
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    abstract public function __toString();
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     */
+    abstract public function getQueryTerms();
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    abstract protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter);
+
+    /**
+     * Highlight matches in $inputHTML
+     *
+     * @param string $inputHTML
+     * @param string  $defaultEncoding   HTML encoding, is used if it's not specified using Content-type HTTP-EQUIV meta tag.
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface|null $highlighter
+     * @return string
+     */
+    public function highlightMatches($inputHTML, $defaultEncoding = '', $highlighter = null)
+    {
+        if ($highlighter === null) {
+               $highlighter = new Zend_Search_Lucene_Search_Highlighter_Default();
+        }
+
+        $doc = Zend_Search_Lucene_Document_Html::loadHTML($inputHTML, false, $defaultEncoding);
+        $highlighter->setDocument($doc);
+
+        $this->_highlightMatches($highlighter);
+
+        return $doc->getHTML();
+    }
+
+    /**
+     * Highlight matches in $inputHtmlFragment and return it (without HTML header and body tag)
+     *
+     * @param string $inputHtmlFragment
+     * @param string  $encoding   Input HTML string encoding
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface|null $highlighter
+     * @return string
+     */
+    public function htmlFragmentHighlightMatches($inputHtmlFragment, $encoding = 'UTF-8', $highlighter = null)
+    {
+        if ($highlighter === null) {
+            $highlighter = new Zend_Search_Lucene_Search_Highlighter_Default();
+        }
+
+        $inputHTML = '<html><head><META HTTP-EQUIV="Content-type" CONTENT="text/html; charset=UTF-8"/></head><body>'
+                   . iconv($encoding, 'UTF-8//IGNORE', $inputHtmlFragment) . '</body></html>';
+
+       $doc = Zend_Search_Lucene_Document_Html::loadHTML($inputHTML);
+        $highlighter->setDocument($doc);
+
+        $this->_highlightMatches($highlighter);
+
+        return $doc->getHtmlBody();
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Boolean.php b/inc/lib/Zend/Search/Lucene/Search/Query/Boolean.php
new file mode 100644 (file)
index 0000000..781b602
--- /dev/null
@@ -0,0 +1,806 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Boolean.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Weight_Boolean */
+require_once 'Zend/Search/Lucene/Search/Weight/Boolean.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Boolean extends Zend_Search_Lucene_Search_Query
+{
+
+    /**
+     * Subqueries
+     * Array of Zend_Search_Lucene_Search_Query
+     *
+     * @var array
+     */
+    private $_subqueries = array();
+
+    /**
+     * Subqueries signs.
+     * If true then subquery is required.
+     * If false then subquery is prohibited.
+     * If null then subquery is neither prohibited, nor required
+     *
+     * If array is null then all subqueries are required
+     *
+     * @var array
+     */
+    private $_signs = array();
+
+    /**
+     * Result vector.
+     *
+     * @var array
+     */
+    private $_resVector = null;
+
+    /**
+     * A score factor based on the fraction of all query subqueries
+     * that a document contains.
+     * float for conjunction queries
+     * array of float for non conjunction queries
+     *
+     * @var mixed
+     */
+    private $_coord = null;
+
+
+    /**
+     * Class constructor.  Create a new Boolean query object.
+     *
+     * if $signs array is omitted then all subqueries are required
+     * it differs from addSubquery() behavior, but should never be used
+     *
+     * @param array $subqueries    Array of Zend_Search_Search_Query objects
+     * @param array $signs    Array of signs.  Sign is boolean|null.
+     * @return void
+     */
+    public function __construct($subqueries = null, $signs = null)
+    {
+        if (is_array($subqueries)) {
+            $this->_subqueries = $subqueries;
+
+            $this->_signs = null;
+            // Check if all subqueries are required
+            if (is_array($signs)) {
+                foreach ($signs as $sign ) {
+                    if ($sign !== true) {
+                        $this->_signs = $signs;
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+
+    /**
+     * Add a $subquery (Zend_Search_Lucene_Search_Query) to this query.
+     *
+     * The sign is specified as:
+     *     TRUE  - subquery is required
+     *     FALSE - subquery is prohibited
+     *     NULL  - subquery is neither prohibited, nor required
+     *
+     * @param  Zend_Search_Lucene_Search_Query $subquery
+     * @param  boolean|null $sign
+     * @return void
+     */
+    public function addSubquery(Zend_Search_Lucene_Search_Query $subquery, $sign=null) {
+        if ($sign !== true || $this->_signs !== null) {       // Skip, if all subqueries are required
+            if ($this->_signs === null) {                     // Check, If all previous subqueries are required
+                $this->_signs = array();
+                foreach ($this->_subqueries as $prevSubquery) {
+                    $this->_signs[] = true;
+                }
+            }
+            $this->_signs[] = $sign;
+        }
+
+        $this->_subqueries[] = $subquery;
+    }
+
+    /**
+     * Re-write queries into primitive queries
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        $query = new Zend_Search_Lucene_Search_Query_Boolean();
+        $query->setBoost($this->getBoost());
+
+        foreach ($this->_subqueries as $subqueryId => $subquery) {
+            $query->addSubquery($subquery->rewrite($index),
+                                ($this->_signs === null)?  true : $this->_signs[$subqueryId]);
+        }
+
+        return $query;
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        $subqueries = array();
+        $signs      = array();
+
+        // Optimize all subqueries
+        foreach ($this->_subqueries as $id => $subquery) {
+            $subqueries[] = $subquery->optimize($index);
+            $signs[]      = ($this->_signs === null)? true : $this->_signs[$id];
+        }
+
+        // Remove insignificant subqueries
+        foreach ($subqueries as $id => $subquery) {
+            if ($subquery instanceof Zend_Search_Lucene_Search_Query_Insignificant) {
+                // Insignificant subquery has to be removed anyway
+                unset($subqueries[$id]);
+                unset($signs[$id]);
+            }
+        }
+        if (count($subqueries) == 0) {
+            // Boolean query doesn't has non-insignificant subqueries
+            return new Zend_Search_Lucene_Search_Query_Insignificant();
+        }
+        // Check if all non-insignificant subqueries are prohibited
+        $allProhibited = true;
+        foreach ($signs as $sign) {
+            if ($sign !== false) {
+                $allProhibited = false;
+                break;
+            }
+        }
+        if ($allProhibited) {
+            return new Zend_Search_Lucene_Search_Query_Insignificant();
+        }
+
+
+        // Check for empty subqueries
+        foreach ($subqueries as $id => $subquery) {
+            if ($subquery instanceof Zend_Search_Lucene_Search_Query_Empty) {
+                if ($signs[$id] === true) {
+                    // Matching is required, but is actually empty
+                    return new Zend_Search_Lucene_Search_Query_Empty();
+                } else {
+                    // Matching is optional or prohibited, but is empty
+                    // Remove it from subqueries and signs list
+                    unset($subqueries[$id]);
+                    unset($signs[$id]);
+                }
+            }
+        }
+
+        // Check, if reduced subqueries list is empty
+        if (count($subqueries) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        }
+
+        // Check if all non-empty subqueries are prohibited
+        $allProhibited = true;
+        foreach ($signs as $sign) {
+            if ($sign !== false) {
+                $allProhibited = false;
+                break;
+            }
+        }
+        if ($allProhibited) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        }
+
+
+        // Check, if reduced subqueries list has only one entry
+        if (count($subqueries) == 1) {
+            // It's a query with only one required or optional clause
+            // (it's already checked, that it's not a prohibited clause)
+
+            if ($this->getBoost() == 1) {
+                return reset($subqueries);
+            }
+
+            $optimizedQuery = clone reset($subqueries);
+            $optimizedQuery->setBoost($optimizedQuery->getBoost()*$this->getBoost());
+
+            return $optimizedQuery;
+        }
+
+
+        // Prepare first candidate for optimized query
+        $optimizedQuery = new Zend_Search_Lucene_Search_Query_Boolean($subqueries, $signs);
+        $optimizedQuery->setBoost($this->getBoost());
+
+
+        $terms        = array();
+        $tsigns       = array();
+        $boostFactors = array();
+
+        // Try to decompose term and multi-term subqueries
+        foreach ($subqueries as $id => $subquery) {
+            if ($subquery instanceof Zend_Search_Lucene_Search_Query_Term) {
+                $terms[]        = $subquery->getTerm();
+                $tsigns[]       = $signs[$id];
+                $boostFactors[] = $subquery->getBoost();
+
+                // remove subquery from a subqueries list
+                unset($subqueries[$id]);
+                unset($signs[$id]);
+           } else if ($subquery instanceof Zend_Search_Lucene_Search_Query_MultiTerm) {
+                $subTerms = $subquery->getTerms();
+                $subSigns = $subquery->getSigns();
+
+                if ($signs[$id] === true) {
+                    // It's a required multi-term subquery.
+                    // Something like '... +(+term1 -term2 term3 ...) ...'
+
+                    // Multi-term required subquery can be decomposed only if it contains
+                    // required terms and doesn't contain prohibited terms:
+                    // ... +(+term1 term2 ...) ... => ... +term1 term2 ...
+                    //
+                    // Check this
+                    $hasRequired   = false;
+                    $hasProhibited = false;
+                    if ($subSigns === null) {
+                        // All subterms are required
+                        $hasRequired = true;
+                    } else {
+                        foreach ($subSigns as $sign) {
+                            if ($sign === true) {
+                                $hasRequired   = true;
+                            } else if ($sign === false) {
+                                $hasProhibited = true;
+                                break;
+                            }
+                        }
+                    }
+                    // Continue if subquery has prohibited terms or doesn't have required terms
+                    if ($hasProhibited  ||  !$hasRequired) {
+                        continue;
+                    }
+
+                    foreach ($subTerms as $termId => $term) {
+                        $terms[]        = $term;
+                        $tsigns[]       = ($subSigns === null)? true : $subSigns[$termId];
+                        $boostFactors[] = $subquery->getBoost();
+                    }
+
+                    // remove subquery from a subqueries list
+                    unset($subqueries[$id]);
+                    unset($signs[$id]);
+
+                } else { // $signs[$id] === null  ||  $signs[$id] === false
+                    // It's an optional or prohibited multi-term subquery.
+                    // Something like '... (+term1 -term2 term3 ...) ...'
+                    // or
+                    // something like '... -(+term1 -term2 term3 ...) ...'
+
+                    // Multi-term optional and required subqueries can be decomposed
+                    // only if all terms are optional.
+                    //
+                    // Check if all terms are optional.
+                    $onlyOptional = true;
+                    if ($subSigns === null) {
+                        // All subterms are required
+                        $onlyOptional = false;
+                    } else {
+                        foreach ($subSigns as $sign) {
+                            if ($sign !== null) {
+                                $onlyOptional = false;
+                                break;
+                            }
+                        }
+                    }
+
+                    // Continue if non-optional terms are presented in this multi-term subquery
+                    if (!$onlyOptional) {
+                        continue;
+                    }
+
+                    foreach ($subTerms as $termId => $term) {
+                        $terms[]  = $term;
+                        $tsigns[] = ($signs[$id] === null)? null  /* optional */ :
+                                                            false /* prohibited */;
+                        $boostFactors[] = $subquery->getBoost();
+                    }
+
+                    // remove subquery from a subqueries list
+                    unset($subqueries[$id]);
+                    unset($signs[$id]);
+                }
+            }
+        }
+
+
+        // Check, if there are no decomposed subqueries
+        if (count($terms) == 0 ) {
+            // return prepared candidate
+            return $optimizedQuery;
+        }
+
+
+        // Check, if all subqueries have been decomposed and all terms has the same boost factor
+        if (count($subqueries) == 0  &&  count(array_unique($boostFactors)) == 1) {
+            $optimizedQuery = new Zend_Search_Lucene_Search_Query_MultiTerm($terms, $tsigns);
+            $optimizedQuery->setBoost(reset($boostFactors)*$this->getBoost());
+
+            return $optimizedQuery;
+        }
+
+
+        // This boolean query can't be transformed to Term/MultiTerm query and still contains
+        // several subqueries
+
+        // Separate prohibited terms
+        $prohibitedTerms        = array();
+        foreach ($terms as $id => $term) {
+            if ($tsigns[$id] === false) {
+                $prohibitedTerms[]        = $term;
+
+                unset($terms[$id]);
+                unset($tsigns[$id]);
+                unset($boostFactors[$id]);
+            }
+        }
+
+        if (count($terms) == 1) {
+            $clause = new Zend_Search_Lucene_Search_Query_Term(reset($terms));
+            $clause->setBoost(reset($boostFactors));
+
+            $subqueries[] = $clause;
+            $signs[]      = reset($tsigns);
+
+            // Clear terms list
+            $terms = array();
+        } else if (count($terms) > 1  &&  count(array_unique($boostFactors)) == 1) {
+            $clause = new Zend_Search_Lucene_Search_Query_MultiTerm($terms, $tsigns);
+            $clause->setBoost(reset($boostFactors));
+
+            $subqueries[] = $clause;
+            // Clause sign is 'required' if clause contains required terms. 'Optional' otherwise.
+            $signs[]      = (in_array(true, $tsigns))? true : null;
+
+            // Clear terms list
+            $terms = array();
+        }
+
+        if (count($prohibitedTerms) == 1) {
+            // (boost factors are not significant for prohibited clauses)
+            $subqueries[] = new Zend_Search_Lucene_Search_Query_Term(reset($prohibitedTerms));
+            $signs[]      = false;
+
+            // Clear prohibited terms list
+            $prohibitedTerms = array();
+        } else if (count($prohibitedTerms) > 1) {
+            // prepare signs array
+            $prohibitedSigns = array();
+            foreach ($prohibitedTerms as $id => $term) {
+                // all prohibited term are grouped as optional into multi-term query
+                $prohibitedSigns[$id] = null;
+            }
+
+            // (boost factors are not significant for prohibited clauses)
+            $subqueries[] = new Zend_Search_Lucene_Search_Query_MultiTerm($prohibitedTerms, $prohibitedSigns);
+            // Clause sign is 'prohibited'
+            $signs[]      = false;
+
+            // Clear terms list
+            $prohibitedTerms = array();
+        }
+
+        /** @todo Group terms with the same boost factors together */
+
+        // Check, that all terms are processed
+        // Replace candidate for optimized query
+        if (count($terms) == 0  &&  count($prohibitedTerms) == 0) {
+            $optimizedQuery = new Zend_Search_Lucene_Search_Query_Boolean($subqueries, $signs);
+            $optimizedQuery->setBoost($this->getBoost());
+        }
+
+        return $optimizedQuery;
+    }
+
+    /**
+     * Returns subqueries
+     *
+     * @return array
+     */
+    public function getSubqueries()
+    {
+        return $this->_subqueries;
+    }
+
+
+    /**
+     * Return subqueries signs
+     *
+     * @return array
+     */
+    public function getSigns()
+    {
+        return $this->_signs;
+    }
+
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        $this->_weight = new Zend_Search_Lucene_Search_Weight_Boolean($this, $reader);
+        return $this->_weight;
+    }
+
+
+    /**
+     * Calculate result vector for Conjunction query
+     * (like '<subquery1> AND <subquery2> AND <subquery3>')
+     */
+    private function _calculateConjunctionResult()
+    {
+        $this->_resVector = null;
+
+        if (count($this->_subqueries) == 0) {
+            $this->_resVector = array();
+        }
+
+        $resVectors      = array();
+        $resVectorsSizes = array();
+        $resVectorsIds   = array(); // is used to prevent arrays comparison
+        foreach ($this->_subqueries as $subqueryId => $subquery) {
+            $resVectors[]      = $subquery->matchedDocs();
+            $resVectorsSizes[] = count(end($resVectors));
+            $resVectorsIds[]   = $subqueryId;
+        }
+        // sort resvectors in order of subquery cardinality increasing
+        array_multisort($resVectorsSizes, SORT_ASC, SORT_NUMERIC,
+                        $resVectorsIds,   SORT_ASC, SORT_NUMERIC,
+                        $resVectors);
+
+        foreach ($resVectors as $nextResVector) {
+            if($this->_resVector === null) {
+                $this->_resVector = $nextResVector;
+            } else {
+                //$this->_resVector = array_intersect_key($this->_resVector, $nextResVector);
+
+                /**
+                 * This code is used as workaround for array_intersect_key() slowness problem.
+                 */
+                $updatedVector = array();
+                foreach ($this->_resVector as $id => $value) {
+                    if (isset($nextResVector[$id])) {
+                        $updatedVector[$id] = $value;
+                    }
+                }
+                $this->_resVector = $updatedVector;
+            }
+
+            if (count($this->_resVector) == 0) {
+                // Empty result set, we don't need to check other terms
+                break;
+            }
+        }
+
+        // ksort($this->_resVector, SORT_NUMERIC);
+        // Used algorithm doesn't change elements order
+    }
+
+
+    /**
+     * Calculate result vector for non Conjunction query
+     * (like '<subquery1> AND <subquery2> AND NOT <subquery3> OR <subquery4>')
+     */
+    private function _calculateNonConjunctionResult()
+    {
+        $requiredVectors      = array();
+        $requiredVectorsSizes = array();
+        $requiredVectorsIds   = array(); // is used to prevent arrays comparison
+
+        $optional = array();
+
+        foreach ($this->_subqueries as $subqueryId => $subquery) {
+            if ($this->_signs[$subqueryId] === true) {
+                // required
+                $requiredVectors[]      = $subquery->matchedDocs();
+                $requiredVectorsSizes[] = count(end($requiredVectors));
+                $requiredVectorsIds[]   = $subqueryId;
+            } elseif ($this->_signs[$subqueryId] === false) {
+                // prohibited
+                // Do nothing. matchedDocs() may include non-matching id's
+                // Calculating prohibited vector may take significant time, but do not affect the result
+                // Skipped.
+            } else {
+                // neither required, nor prohibited
+                // array union
+                $optional += $subquery->matchedDocs();
+            }
+        }
+
+        // sort resvectors in order of subquery cardinality increasing
+        array_multisort($requiredVectorsSizes, SORT_ASC, SORT_NUMERIC,
+                        $requiredVectorsIds,   SORT_ASC, SORT_NUMERIC,
+                        $requiredVectors);
+
+        $required = null;
+        foreach ($requiredVectors as $nextResVector) {
+            if($required === null) {
+                $required = $nextResVector;
+            } else {
+                //$required = array_intersect_key($required, $nextResVector);
+
+                /**
+                 * This code is used as workaround for array_intersect_key() slowness problem.
+                 */
+                $updatedVector = array();
+                foreach ($required as $id => $value) {
+                    if (isset($nextResVector[$id])) {
+                        $updatedVector[$id] = $value;
+                    }
+                }
+                $required = $updatedVector;
+            }
+
+            if (count($required) == 0) {
+                // Empty result set, we don't need to check other terms
+                break;
+            }
+        }
+
+
+        if ($required !== null) {
+            $this->_resVector = &$required;
+        } else {
+            $this->_resVector = &$optional;
+        }
+
+        ksort($this->_resVector, SORT_NUMERIC);
+    }
+
+
+    /**
+     * Score calculator for conjunction queries (all subqueries are required)
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function _conjunctionScore($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        if ($this->_coord === null) {
+            $this->_coord = $reader->getSimilarity()->coord(count($this->_subqueries),
+                                                            count($this->_subqueries) );
+        }
+
+        $score = 0;
+
+        foreach ($this->_subqueries as $subquery) {
+            $subscore = $subquery->score($docId, $reader);
+
+            if ($subscore == 0) {
+                return 0;
+            }
+
+            $score += $subquery->score($docId, $reader) * $this->_coord;
+        }
+
+        return $score * $this->_coord * $this->getBoost();
+    }
+
+
+    /**
+     * Score calculator for non conjunction queries (not all subqueries are required)
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function _nonConjunctionScore($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        if ($this->_coord === null) {
+            $this->_coord = array();
+
+            $maxCoord = 0;
+            foreach ($this->_signs as $sign) {
+                if ($sign !== false /* not prohibited */) {
+                    $maxCoord++;
+                }
+            }
+
+            for ($count = 0; $count <= $maxCoord; $count++) {
+                $this->_coord[$count] = $reader->getSimilarity()->coord($count, $maxCoord);
+            }
+        }
+
+        $score = 0;
+        $matchedSubqueries = 0;
+        foreach ($this->_subqueries as $subqueryId => $subquery) {
+            $subscore = $subquery->score($docId, $reader);
+
+            // Prohibited
+            if ($this->_signs[$subqueryId] === false && $subscore != 0) {
+                return 0;
+            }
+
+            // is required, but doen't match
+            if ($this->_signs[$subqueryId] === true &&  $subscore == 0) {
+                return 0;
+            }
+
+            if ($subscore != 0) {
+                $matchedSubqueries++;
+                $score += $subscore;
+            }
+        }
+
+        return $score * $this->_coord[$matchedSubqueries] * $this->getBoost();
+    }
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        // Initialize weight if it's not done yet
+        $this->_initWeight($reader);
+
+        if ($docsFilter === null) {
+            // Create local documents filter if it's not provided by upper query
+            $docsFilter = new Zend_Search_Lucene_Index_DocsFilter();
+        }
+
+        foreach ($this->_subqueries as $subqueryId => $subquery) {
+            if ($this->_signs == null  ||  $this->_signs[$subqueryId] === true) {
+                // Subquery is required
+                $subquery->execute($reader, $docsFilter);
+            } else {
+                $subquery->execute($reader);
+            }
+        }
+
+        if ($this->_signs === null) {
+            $this->_calculateConjunctionResult();
+        } else {
+            $this->_calculateNonConjunctionResult();
+        }
+    }
+
+
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     */
+    public function matchedDocs()
+    {
+        return $this->_resVector;
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        if (isset($this->_resVector[$docId])) {
+            if ($this->_signs === null) {
+                return $this->_conjunctionScore($docId, $reader);
+            } else {
+                return $this->_nonConjunctionScore($docId, $reader);
+            }
+        } else {
+            return 0;
+        }
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     */
+    public function getQueryTerms()
+    {
+        $terms = array();
+
+        foreach ($this->_subqueries as $id => $subquery) {
+            if ($this->_signs === null  ||  $this->_signs[$id] !== false) {
+                $terms = array_merge($terms, $subquery->getQueryTerms());
+            }
+        }
+
+        return $terms;
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        foreach ($this->_subqueries as $id => $subquery) {
+            if ($this->_signs === null  ||  $this->_signs[$id] !== false) {
+                $subquery->_highlightMatches($highlighter);
+            }
+        }
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        // It's used only for query visualisation, so we don't care about characters escaping
+
+        $query = '';
+
+        foreach ($this->_subqueries as $id => $subquery) {
+            if ($id != 0) {
+                $query .= ' ';
+            }
+
+            if ($this->_signs === null || $this->_signs[$id] === true) {
+                $query .= '+';
+            } else if ($this->_signs[$id] === false) {
+                $query .= '-';
+            }
+
+            $query .= '(' . $subquery->__toString() . ')';
+        }
+
+        if ($this->getBoost() != 1) {
+            $query = '(' . $query . ')^' . round($this->getBoost(), 4);
+        }
+
+        return $query;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Empty.php b/inc/lib/Zend/Search/Lucene/Search/Query/Empty.php
new file mode 100644 (file)
index 0000000..2c6b935
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Empty.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Weight_Empty */
+require_once 'Zend/Search/Lucene/Search/Weight/Empty.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Empty extends Zend_Search_Lucene_Search_Query
+{
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        return $this;
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        // "Empty" query is a primitive query and don't need to be optimized
+        return $this;
+    }
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        return new Zend_Search_Lucene_Search_Weight_Empty();
+    }
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        // Do nothing
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     */
+    public function matchedDocs()
+    {
+        return array();
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        return 0;
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     */
+    public function getQueryTerms()
+    {
+        return array();
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        // Do nothing
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return '<EmptyQuery>';
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Fuzzy.php b/inc/lib/Zend/Search/Lucene/Search/Query/Fuzzy.php
new file mode 100644 (file)
index 0000000..8f8d78c
--- /dev/null
@@ -0,0 +1,488 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Fuzzy.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Query_MultiTerm */
+require_once 'Zend/Search/Lucene/Search/Query/MultiTerm.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Fuzzy extends Zend_Search_Lucene_Search_Query
+{
+    /** Default minimum similarity */
+    const DEFAULT_MIN_SIMILARITY = 0.5;
+
+    /**
+     * Maximum number of matched terms.
+     * Apache Lucene defines this limitation as boolean query maximum number of clauses:
+     * org.apache.lucene.search.BooleanQuery.getMaxClauseCount()
+     */
+    const MAX_CLAUSE_COUNT = 1024;
+
+    /**
+     * Array of precalculated max distances
+     *
+     * keys are integers representing a word size
+     */
+    private $_maxDistances = array();
+
+    /**
+     * Base searching term.
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_term;
+
+    /**
+     * A value between 0 and 1 to set the required similarity
+     *  between the query term and the matching terms. For example, for a
+     *  _minimumSimilarity of 0.5 a term of the same length
+     *  as the query term is considered similar to the query term if the edit distance
+     *  between both terms is less than length(term)*0.5
+     *
+     * @var float
+     */
+    private $_minimumSimilarity;
+
+    /**
+     * The length of common (non-fuzzy) prefix
+     *
+     * @var integer
+     */
+    private $_prefixLength;
+
+    /**
+     * Matched terms.
+     *
+     * Matched terms list.
+     * It's filled during the search (rewrite operation) and may be used for search result
+     * post-processing
+     *
+     * Array of Zend_Search_Lucene_Index_Term objects
+     *
+     * @var array
+     */
+    private $_matches = null;
+
+    /**
+     * Matched terms scores
+     *
+     * @var array
+     */
+    private $_scores = null;
+
+    /**
+     * Array of the term keys.
+     * Used to sort terms in alphabetical order if terms have the same socres
+     *
+     * @var array
+     */
+    private $_termKeys = null;
+
+    /**
+     * Default non-fuzzy prefix length
+     *
+     * @var integer
+     */
+    private static $_defaultPrefixLength = 3;
+
+    /**
+     * Zend_Search_Lucene_Search_Query_Wildcard constructor.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param float   $minimumSimilarity
+     * @param integer $prefixLength
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct(Zend_Search_Lucene_Index_Term $term, $minimumSimilarity = self::DEFAULT_MIN_SIMILARITY, $prefixLength = null)
+    {
+        if ($minimumSimilarity < 0) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('minimumSimilarity cannot be less than 0');
+        }
+        if ($minimumSimilarity >= 1) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('minimumSimilarity cannot be greater than or equal to 1');
+        }
+        if ($prefixLength < 0) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('prefixLength cannot be less than 0');
+        }
+
+        $this->_term              = $term;
+        $this->_minimumSimilarity = $minimumSimilarity;
+        $this->_prefixLength      = ($prefixLength !== null)? $prefixLength : self::$_defaultPrefixLength;
+    }
+
+    /**
+     * Get default non-fuzzy prefix length
+     *
+     * @return integer
+     */
+    public static function getDefaultPrefixLength()
+    {
+        return self::$_defaultPrefixLength;
+    }
+
+    /**
+     * Set default non-fuzzy prefix length
+     *
+     * @param integer $defaultPrefixLength
+     */
+    public static function setDefaultPrefixLength($defaultPrefixLength)
+    {
+        self::$_defaultPrefixLength = $defaultPrefixLength;
+    }
+
+    /**
+     * Calculate maximum distance for specified word length
+     *
+     * @param integer $prefixLength
+     * @param integer $termLength
+     * @param integer $length
+     * @return integer
+     */
+    private function _calculateMaxDistance($prefixLength, $termLength, $length)
+    {
+        $this->_maxDistances[$length] = (int) ((1 - $this->_minimumSimilarity)*(min($termLength, $length) + $prefixLength));
+        return $this->_maxDistances[$length];
+    }
+
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        $this->_matches  = array();
+        $this->_scores   = array();
+        $this->_termKeys = array();
+
+        if ($this->_term->field === null) {
+            // Search through all fields
+            $fields = $index->getFieldNames(true /* indexed fields list */);
+        } else {
+            $fields = array($this->_term->field);
+        }
+
+        $prefix           = Zend_Search_Lucene_Index_Term::getPrefix($this->_term->text, $this->_prefixLength);
+        $prefixByteLength = strlen($prefix);
+        $prefixUtf8Length = Zend_Search_Lucene_Index_Term::getLength($prefix);
+
+        $termLength       = Zend_Search_Lucene_Index_Term::getLength($this->_term->text);
+
+        $termRest         = substr($this->_term->text, $prefixByteLength);
+        // we calculate length of the rest in bytes since levenshtein() is not UTF-8 compatible
+        $termRestLength   = strlen($termRest);
+
+        $scaleFactor = 1/(1 - $this->_minimumSimilarity);
+
+        $maxTerms = Zend_Search_Lucene::getTermsPerQueryLimit();
+        foreach ($fields as $field) {
+            $index->resetTermsStream();
+
+            if ($prefix != '') {
+                $index->skipTo(new Zend_Search_Lucene_Index_Term($prefix, $field));
+
+                while ($index->currentTerm() !== null          &&
+                       $index->currentTerm()->field == $field  &&
+                       substr($index->currentTerm()->text, 0, $prefixByteLength) == $prefix) {
+                    // Calculate similarity
+                    $target = substr($index->currentTerm()->text, $prefixByteLength);
+
+                    $maxDistance = isset($this->_maxDistances[strlen($target)])?
+                                       $this->_maxDistances[strlen($target)] :
+                                       $this->_calculateMaxDistance($prefixUtf8Length, $termRestLength, strlen($target));
+
+                    if ($termRestLength == 0) {
+                        // we don't have anything to compare.  That means if we just add
+                        // the letters for current term we get the new word
+                        $similarity = (($prefixUtf8Length == 0)? 0 : 1 - strlen($target)/$prefixUtf8Length);
+                    } else if (strlen($target) == 0) {
+                        $similarity = (($prefixUtf8Length == 0)? 0 : 1 - $termRestLength/$prefixUtf8Length);
+                    } else if ($maxDistance < abs($termRestLength - strlen($target))){
+                        //just adding the characters of term to target or vice-versa results in too many edits
+                        //for example "pre" length is 3 and "prefixes" length is 8.  We can see that
+                        //given this optimal circumstance, the edit distance cannot be less than 5.
+                        //which is 8-3 or more precisesly abs(3-8).
+                        //if our maximum edit distance is 4, then we can discard this word
+                        //without looking at it.
+                        $similarity = 0;
+                    } else {
+                        $similarity = 1 - levenshtein($termRest, $target)/($prefixUtf8Length + min($termRestLength, strlen($target)));
+                    }
+
+                    if ($similarity > $this->_minimumSimilarity) {
+                        $this->_matches[]  = $index->currentTerm();
+                        $this->_termKeys[] = $index->currentTerm()->key();
+                        $this->_scores[]   = ($similarity - $this->_minimumSimilarity)*$scaleFactor;
+
+                        if ($maxTerms != 0  &&  count($this->_matches) > $maxTerms) {
+                            require_once 'Zend/Search/Lucene/Exception.php';
+                            throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
+                        }
+                    }
+
+                    $index->nextTerm();
+                }
+            } else {
+                $index->skipTo(new Zend_Search_Lucene_Index_Term('', $field));
+
+                while ($index->currentTerm() !== null  &&  $index->currentTerm()->field == $field) {
+                    // Calculate similarity
+                    $target = $index->currentTerm()->text;
+
+                    $maxDistance = isset($this->_maxDistances[strlen($target)])?
+                                       $this->_maxDistances[strlen($target)] :
+                                       $this->_calculateMaxDistance(0, $termRestLength, strlen($target));
+
+                    if ($maxDistance < abs($termRestLength - strlen($target))){
+                        //just adding the characters of term to target or vice-versa results in too many edits
+                        //for example "pre" length is 3 and "prefixes" length is 8.  We can see that
+                        //given this optimal circumstance, the edit distance cannot be less than 5.
+                        //which is 8-3 or more precisesly abs(3-8).
+                        //if our maximum edit distance is 4, then we can discard this word
+                        //without looking at it.
+                        $similarity = 0;
+                    } else {
+                        $similarity = 1 - levenshtein($termRest, $target)/min($termRestLength, strlen($target));
+                    }
+
+                    if ($similarity > $this->_minimumSimilarity) {
+                        $this->_matches[]  = $index->currentTerm();
+                        $this->_termKeys[] = $index->currentTerm()->key();
+                        $this->_scores[]   = ($similarity - $this->_minimumSimilarity)*$scaleFactor;
+
+                        if ($maxTerms != 0  &&  count($this->_matches) > $maxTerms) {
+                            require_once 'Zend/Search/Lucene/Exception.php';
+                            throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
+                        }
+                    }
+
+                    $index->nextTerm();
+                }
+            }
+
+            $index->closeTermsStream();
+        }
+
+        if (count($this->_matches) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        } else if (count($this->_matches) == 1) {
+            return new Zend_Search_Lucene_Search_Query_Term(reset($this->_matches));
+        } else {
+            $rewrittenQuery = new Zend_Search_Lucene_Search_Query_Boolean();
+
+            array_multisort($this->_scores,   SORT_DESC, SORT_NUMERIC,
+                            $this->_termKeys, SORT_ASC,  SORT_STRING,
+                            $this->_matches);
+
+            $termCount = 0;
+            foreach ($this->_matches as $id => $matchedTerm) {
+                $subquery = new Zend_Search_Lucene_Search_Query_Term($matchedTerm);
+                $subquery->setBoost($this->_scores[$id]);
+
+                $rewrittenQuery->addSubquery($subquery);
+
+                $termCount++;
+                if ($termCount >= self::MAX_CLAUSE_COUNT) {
+                    break;
+                }
+            }
+
+            return $rewrittenQuery;
+        }
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function getQueryTerms()
+    {
+        if ($this->_matches === null) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Search or rewrite operations have to be performed before.');
+        }
+
+        return $this->_matches;
+    }
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function matchedDocs()
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        $words = array();
+
+        $prefix           = Zend_Search_Lucene_Index_Term::getPrefix($this->_term->text, $this->_prefixLength);
+        $prefixByteLength = strlen($prefix);
+        $prefixUtf8Length = Zend_Search_Lucene_Index_Term::getLength($prefix);
+
+        $termLength       = Zend_Search_Lucene_Index_Term::getLength($this->_term->text);
+
+        $termRest         = substr($this->_term->text, $prefixByteLength);
+        // we calculate length of the rest in bytes since levenshtein() is not UTF-8 compatible
+        $termRestLength   = strlen($termRest);
+
+        $scaleFactor = 1/(1 - $this->_minimumSimilarity);
+
+
+        $docBody = $highlighter->getDocument()->getFieldUtf8Value('body');
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($docBody, 'UTF-8');
+        foreach ($tokens as $token) {
+               $termText = $token->getTermText();
+
+               if (substr($termText, 0, $prefixByteLength) == $prefix) {
+                // Calculate similarity
+                $target = substr($termText, $prefixByteLength);
+
+                $maxDistance = isset($this->_maxDistances[strlen($target)])?
+                                   $this->_maxDistances[strlen($target)] :
+                                   $this->_calculateMaxDistance($prefixUtf8Length, $termRestLength, strlen($target));
+
+                if ($termRestLength == 0) {
+                    // we don't have anything to compare.  That means if we just add
+                    // the letters for current term we get the new word
+                    $similarity = (($prefixUtf8Length == 0)? 0 : 1 - strlen($target)/$prefixUtf8Length);
+                } else if (strlen($target) == 0) {
+                    $similarity = (($prefixUtf8Length == 0)? 0 : 1 - $termRestLength/$prefixUtf8Length);
+                } else if ($maxDistance < abs($termRestLength - strlen($target))){
+                    //just adding the characters of term to target or vice-versa results in too many edits
+                    //for example "pre" length is 3 and "prefixes" length is 8.  We can see that
+                    //given this optimal circumstance, the edit distance cannot be less than 5.
+                    //which is 8-3 or more precisesly abs(3-8).
+                    //if our maximum edit distance is 4, then we can discard this word
+                    //without looking at it.
+                    $similarity = 0;
+                } else {
+                    $similarity = 1 - levenshtein($termRest, $target)/($prefixUtf8Length + min($termRestLength, strlen($target)));
+                }
+
+                if ($similarity > $this->_minimumSimilarity) {
+                    $words[] = $termText;
+                }
+            }
+        }
+
+        $highlighter->highlight($words);
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        // It's used only for query visualisation, so we don't care about characters escaping
+        return (($this->_term->field === null)? '' : $this->_term->field . ':')
+             . $this->_term->text . '~'
+             . (($this->_minimumSimilarity != self::DEFAULT_MIN_SIMILARITY)? round($this->_minimumSimilarity, 4) : '')
+             . (($this->getBoost() != 1)? '^' . round($this->getBoost(), 4) : '');
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Insignificant.php b/inc/lib/Zend/Search/Lucene/Search/Query/Insignificant.php
new file mode 100644 (file)
index 0000000..16d22d0
--- /dev/null
@@ -0,0 +1,141 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Insignificant.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Weight_Empty */
+require_once 'Zend/Search/Lucene/Search/Weight/Empty.php';
+
+
+/**
+ * The insignificant query returns empty result, but doesn't limit result set as a part of other queries
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Insignificant extends Zend_Search_Lucene_Search_Query
+{
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        return $this;
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        return $this;
+    }
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        return new Zend_Search_Lucene_Search_Weight_Empty();
+    }
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        // Do nothing
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     */
+    public function matchedDocs()
+    {
+        return array();
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        return 0;
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     */
+    public function getQueryTerms()
+    {
+        return array();
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        // Do nothing
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return '<InsignificantQuery>';
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/MultiTerm.php b/inc/lib/Zend/Search/Lucene/Search/Query/MultiTerm.php
new file mode 100644 (file)
index 0000000..c57bcb5
--- /dev/null
@@ -0,0 +1,661 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: MultiTerm.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Weight_MultiTerm */
+require_once 'Zend/Search/Lucene/Search/Weight/MultiTerm.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_MultiTerm extends Zend_Search_Lucene_Search_Query
+{
+
+    /**
+     * Terms to find.
+     * Array of Zend_Search_Lucene_Index_Term
+     *
+     * @var array
+     */
+    private $_terms = array();
+
+    /**
+     * Term signs.
+     * If true then term is required.
+     * If false then term is prohibited.
+     * If null then term is neither prohibited, nor required
+     *
+     * If array is null then all terms are required
+     *
+     * @var array
+     */
+    private $_signs;
+
+    /**
+     * Result vector.
+     *
+     * @var array
+     */
+    private $_resVector = null;
+
+    /**
+     * Terms positions vectors.
+     * Array of Arrays:
+     * term1Id => (docId => freq, ...)
+     * term2Id => (docId => freq, ...)
+     *
+     * @var array
+     */
+    private $_termsFreqs = array();
+
+
+    /**
+     * A score factor based on the fraction of all query terms
+     * that a document contains.
+     * float for conjunction queries
+     * array of float for non conjunction queries
+     *
+     * @var mixed
+     */
+    private $_coord = null;
+
+
+    /**
+     * Terms weights
+     * array of Zend_Search_Lucene_Search_Weight
+     *
+     * @var array
+     */
+    private $_weights = array();
+
+
+    /**
+     * Class constructor.  Create a new multi-term query object.
+     *
+     * if $signs array is omitted then all terms are required
+     * it differs from addTerm() behavior, but should never be used
+     *
+     * @param array $terms    Array of Zend_Search_Lucene_Index_Term objects
+     * @param array $signs    Array of signs.  Sign is boolean|null.
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct($terms = null, $signs = null)
+    {
+        if (is_array($terms)) {
+            if (count($terms) > Zend_Search_Lucene::getTermsPerQueryLimit()) {
+                throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
+            }
+
+            $this->_terms = $terms;
+
+            $this->_signs = null;
+            // Check if all terms are required
+            if (is_array($signs)) {
+                foreach ($signs as $sign ) {
+                    if ($sign !== true) {
+                        $this->_signs = $signs;
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+
+    /**
+     * Add a $term (Zend_Search_Lucene_Index_Term) to this query.
+     *
+     * The sign is specified as:
+     *     TRUE  - term is required
+     *     FALSE - term is prohibited
+     *     NULL  - term is neither prohibited, nor required
+     *
+     * @param  Zend_Search_Lucene_Index_Term $term
+     * @param  boolean|null $sign
+     * @return void
+     */
+    public function addTerm(Zend_Search_Lucene_Index_Term $term, $sign = null) {
+        if ($sign !== true || $this->_signs !== null) {       // Skip, if all terms are required
+            if ($this->_signs === null) {                     // Check, If all previous terms are required
+                $this->_signs = array();
+                foreach ($this->_terms as $prevTerm) {
+                    $this->_signs[] = true;
+                }
+            }
+            $this->_signs[] = $sign;
+        }
+
+        $this->_terms[] = $term;
+    }
+
+
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        if (count($this->_terms) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        }
+
+        // Check, that all fields are qualified
+        $allQualified = true;
+        foreach ($this->_terms as $term) {
+            if ($term->field === null) {
+                $allQualified = false;
+                break;
+            }
+        }
+
+        if ($allQualified) {
+            return $this;
+        } else {
+            /** transform multiterm query to boolean and apply rewrite() method to subqueries. */
+            $query = new Zend_Search_Lucene_Search_Query_Boolean();
+            $query->setBoost($this->getBoost());
+
+            foreach ($this->_terms as $termId => $term) {
+                $subquery = new Zend_Search_Lucene_Search_Query_Term($term);
+
+                $query->addSubquery($subquery->rewrite($index),
+                                    ($this->_signs === null)?  true : $this->_signs[$termId]);
+            }
+
+            return $query;
+        }
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        $terms = $this->_terms;
+        $signs = $this->_signs;
+
+        foreach ($terms as $id => $term) {
+            if (!$index->hasTerm($term)) {
+                if ($signs === null  ||  $signs[$id] === true) {
+                    // Term is required
+                    return new Zend_Search_Lucene_Search_Query_Empty();
+                } else {
+                    // Term is optional or prohibited
+                    // Remove it from terms and signs list
+                    unset($terms[$id]);
+                    unset($signs[$id]);
+                }
+            }
+        }
+
+        // Check if all presented terms are prohibited
+        $allProhibited = true;
+        if ($signs === null) {
+            $allProhibited = false;
+        } else {
+            foreach ($signs as $sign) {
+                if ($sign !== false) {
+                    $allProhibited = false;
+                    break;
+                }
+            }
+        }
+        if ($allProhibited) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        }
+
+        /**
+         * @todo make an optimization for repeated terms
+         * (they may have different signs)
+         */
+
+        if (count($terms) == 1) {
+            // It's already checked, that it's not a prohibited term
+
+            // It's one term query with one required or optional element
+            $optimizedQuery = new Zend_Search_Lucene_Search_Query_Term(reset($terms));
+            $optimizedQuery->setBoost($this->getBoost());
+
+            return $optimizedQuery;
+        }
+
+        if (count($terms) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        }
+
+        $optimizedQuery = new Zend_Search_Lucene_Search_Query_MultiTerm($terms, $signs);
+        $optimizedQuery->setBoost($this->getBoost());
+        return $optimizedQuery;
+    }
+
+
+    /**
+     * Returns query term
+     *
+     * @return array
+     */
+    public function getTerms()
+    {
+        return $this->_terms;
+    }
+
+
+    /**
+     * Return terms signs
+     *
+     * @return array
+     */
+    public function getSigns()
+    {
+        return $this->_signs;
+    }
+
+
+    /**
+     * Set weight for specified term
+     *
+     * @param integer $num
+     * @param Zend_Search_Lucene_Search_Weight_Term $weight
+     */
+    public function setWeight($num, $weight)
+    {
+        $this->_weights[$num] = $weight;
+    }
+
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        $this->_weight = new Zend_Search_Lucene_Search_Weight_MultiTerm($this, $reader);
+        return $this->_weight;
+    }
+
+
+    /**
+     * Calculate result vector for Conjunction query
+     * (like '+something +another')
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     */
+    private function _calculateConjunctionResult(Zend_Search_Lucene_Interface $reader)
+    {
+        $this->_resVector = null;
+
+        if (count($this->_terms) == 0) {
+            $this->_resVector = array();
+        }
+
+        // Order terms by selectivity
+        $docFreqs = array();
+        $ids      = array();
+        foreach ($this->_terms as $id => $term) {
+            $docFreqs[] = $reader->docFreq($term);
+            $ids[]      = $id; // Used to keep original order for terms with the same selectivity and omit terms comparison
+        }
+        array_multisort($docFreqs, SORT_ASC, SORT_NUMERIC,
+                        $ids,      SORT_ASC, SORT_NUMERIC,
+                        $this->_terms);
+
+        $docsFilter = new Zend_Search_Lucene_Index_DocsFilter();
+        foreach ($this->_terms as $termId => $term) {
+            $termDocs = $reader->termDocs($term, $docsFilter);
+        }
+        // Treat last retrieved docs vector as a result set
+        // (filter collects data for other terms)
+        $this->_resVector = array_flip($termDocs);
+
+        foreach ($this->_terms as $termId => $term) {
+            $this->_termsFreqs[$termId] = $reader->termFreqs($term, $docsFilter);
+        }
+
+        // ksort($this->_resVector, SORT_NUMERIC);
+        // Docs are returned ordered. Used algorithms doesn't change elements order.
+    }
+
+
+    /**
+     * Calculate result vector for non Conjunction query
+     * (like '+something -another')
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     */
+    private function _calculateNonConjunctionResult(Zend_Search_Lucene_Interface $reader)
+    {
+        $requiredVectors      = array();
+        $requiredVectorsSizes = array();
+        $requiredVectorsIds   = array(); // is used to prevent arrays comparison
+
+        $optional   = array();
+        $prohibited = array();
+
+        foreach ($this->_terms as $termId => $term) {
+            $termDocs = array_flip($reader->termDocs($term));
+
+            if ($this->_signs[$termId] === true) {
+                // required
+                $requiredVectors[]      = $termDocs;
+                $requiredVectorsSizes[] = count($termDocs);
+                $requiredVectorsIds[]   = $termId;
+            } elseif ($this->_signs[$termId] === false) {
+                // prohibited
+                // array union
+                $prohibited += $termDocs;
+            } else {
+                // neither required, nor prohibited
+                // array union
+                $optional += $termDocs;
+            }
+
+            $this->_termsFreqs[$termId] = $reader->termFreqs($term);
+        }
+
+        // sort resvectors in order of subquery cardinality increasing
+        array_multisort($requiredVectorsSizes, SORT_ASC, SORT_NUMERIC,
+                        $requiredVectorsIds,   SORT_ASC, SORT_NUMERIC,
+                        $requiredVectors);
+
+        $required = null;
+        foreach ($requiredVectors as $nextResVector) {
+            if($required === null) {
+                $required = $nextResVector;
+            } else {
+                //$required = array_intersect_key($required, $nextResVector);
+
+                /**
+                 * This code is used as workaround for array_intersect_key() slowness problem.
+                 */
+                $updatedVector = array();
+                foreach ($required as $id => $value) {
+                    if (isset($nextResVector[$id])) {
+                        $updatedVector[$id] = $value;
+                    }
+                }
+                $required = $updatedVector;
+            }
+
+            if (count($required) == 0) {
+                // Empty result set, we don't need to check other terms
+                break;
+            }
+        }
+
+        if ($required !== null) {
+            $this->_resVector = $required;
+        } else {
+            $this->_resVector = $optional;
+        }
+
+        if (count($prohibited) != 0) {
+            // $this->_resVector = array_diff_key($this->_resVector, $prohibited);
+
+            /**
+             * This code is used as workaround for array_diff_key() slowness problem.
+             */
+            if (count($this->_resVector) < count($prohibited)) {
+                $updatedVector = $this->_resVector;
+                foreach ($this->_resVector as $id => $value) {
+                    if (isset($prohibited[$id])) {
+                        unset($updatedVector[$id]);
+                    }
+                }
+                $this->_resVector = $updatedVector;
+            } else {
+                $updatedVector = $this->_resVector;
+                foreach ($prohibited as $id => $value) {
+                    unset($updatedVector[$id]);
+                }
+                $this->_resVector = $updatedVector;
+            }
+        }
+
+        ksort($this->_resVector, SORT_NUMERIC);
+    }
+
+
+    /**
+     * Score calculator for conjunction queries (all terms are required)
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function _conjunctionScore($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        if ($this->_coord === null) {
+            $this->_coord = $reader->getSimilarity()->coord(count($this->_terms),
+                                                            count($this->_terms) );
+        }
+
+        $score = 0.0;
+
+        foreach ($this->_terms as $termId => $term) {
+            /**
+             * We don't need to check that term freq is not 0
+             * Score calculation is performed only for matched docs
+             */
+            $score += $reader->getSimilarity()->tf($this->_termsFreqs[$termId][$docId]) *
+                      $this->_weights[$termId]->getValue() *
+                      $reader->norm($docId, $term->field);
+        }
+
+        return $score * $this->_coord * $this->getBoost();
+    }
+
+
+    /**
+     * Score calculator for non conjunction queries (not all terms are required)
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function _nonConjunctionScore($docId, $reader)
+    {
+        if ($this->_coord === null) {
+            $this->_coord = array();
+
+            $maxCoord = 0;
+            foreach ($this->_signs as $sign) {
+                if ($sign !== false /* not prohibited */) {
+                    $maxCoord++;
+                }
+            }
+
+            for ($count = 0; $count <= $maxCoord; $count++) {
+                $this->_coord[$count] = $reader->getSimilarity()->coord($count, $maxCoord);
+            }
+        }
+
+        $score = 0.0;
+        $matchedTerms = 0;
+        foreach ($this->_terms as $termId=>$term) {
+            // Check if term is
+            if ($this->_signs[$termId] !== false &&        // not prohibited
+                isset($this->_termsFreqs[$termId][$docId]) // matched
+               ) {
+                $matchedTerms++;
+
+                /**
+                 * We don't need to check that term freq is not 0
+                 * Score calculation is performed only for matched docs
+                 */
+                $score +=
+                      $reader->getSimilarity()->tf($this->_termsFreqs[$termId][$docId]) *
+                      $this->_weights[$termId]->getValue() *
+                      $reader->norm($docId, $term->field);
+            }
+        }
+
+        return $score * $this->_coord[$matchedTerms] * $this->getBoost();
+    }
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        if ($this->_signs === null) {
+            $this->_calculateConjunctionResult($reader);
+        } else {
+            $this->_calculateNonConjunctionResult($reader);
+        }
+
+        // Initialize weight if it's not done yet
+        $this->_initWeight($reader);
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     */
+    public function matchedDocs()
+    {
+        return $this->_resVector;
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        if (isset($this->_resVector[$docId])) {
+            if ($this->_signs === null) {
+                return $this->_conjunctionScore($docId, $reader);
+            } else {
+                return $this->_nonConjunctionScore($docId, $reader);
+            }
+        } else {
+            return 0;
+        }
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     */
+    public function getQueryTerms()
+    {
+        if ($this->_signs === null) {
+            return $this->_terms;
+        }
+
+        $terms = array();
+
+        foreach ($this->_signs as $id => $sign) {
+            if ($sign !== false) {
+                $terms[] = $this->_terms[$id];
+            }
+        }
+
+        return $terms;
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        $words = array();
+
+        if ($this->_signs === null) {
+            foreach ($this->_terms as $term) {
+                $words[] = $term->text;
+            }
+        } else {
+            foreach ($this->_signs as $id => $sign) {
+                if ($sign !== false) {
+                    $words[] = $this->_terms[$id]->text;
+                }
+            }
+        }
+
+        $highlighter->highlight($words);
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        // It's used only for query visualisation, so we don't care about characters escaping
+
+        $query = '';
+
+        foreach ($this->_terms as $id => $term) {
+            if ($id != 0) {
+                $query .= ' ';
+            }
+
+            if ($this->_signs === null || $this->_signs[$id] === true) {
+                $query .= '+';
+            } else if ($this->_signs[$id] === false) {
+                $query .= '-';
+            }
+
+            if ($term->field !== null) {
+                $query .= $term->field . ':';
+            }
+            $query .= $term->text;
+        }
+
+        if ($this->getBoost() != 1) {
+            $query = '(' . $query . ')^' . round($this->getBoost(), 4);
+        }
+
+        return $query;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Phrase.php b/inc/lib/Zend/Search/Lucene/Search/Query/Phrase.php
new file mode 100644 (file)
index 0000000..a98c590
--- /dev/null
@@ -0,0 +1,571 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Phrase.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * Zend_Search_Lucene_Search_Query
+ */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/**
+ * Zend_Search_Lucene_Search_Weight_Phrase
+ */
+require_once 'Zend/Search/Lucene/Search/Weight/Phrase.php';
+
+
+/**
+ * A Query that matches documents containing a particular sequence of terms.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Phrase extends Zend_Search_Lucene_Search_Query
+{
+    /**
+     * Terms to find.
+     * Array of Zend_Search_Lucene_Index_Term objects.
+     *
+     * @var array
+     */
+    private $_terms;
+
+    /**
+     * Term positions (relative positions of terms within the phrase).
+     * Array of integers
+     *
+     * @var array
+     */
+    private $_offsets;
+
+    /**
+     * Sets the number of other words permitted between words in query phrase.
+     * If zero, then this is an exact phrase search.  For larger values this works
+     * like a WITHIN or NEAR operator.
+     *
+     * The slop is in fact an edit-distance, where the units correspond to
+     * moves of terms in the query phrase out of position.  For example, to switch
+     * the order of two words requires two moves (the first move places the words
+     * atop one another), so to permit re-orderings of phrases, the slop must be
+     * at least two.
+     * More exact matches are scored higher than sloppier matches, thus search
+     * results are sorted by exactness.
+     *
+     * The slop is zero by default, requiring exact matches.
+     *
+     * @var integer
+     */
+    private $_slop;
+
+    /**
+     * Result vector.
+     *
+     * @var array
+     */
+    private $_resVector = null;
+
+    /**
+     * Terms positions vectors.
+     * Array of Arrays:
+     * term1Id => (docId => array( pos1, pos2, ... ), ...)
+     * term2Id => (docId => array( pos1, pos2, ... ), ...)
+     *
+     * @var array
+     */
+    private $_termsPositions = array();
+
+    /**
+     * Class constructor.  Create a new prase query.
+     *
+     * @param string $field    Field to search.
+     * @param array  $terms    Terms to search Array of strings.
+     * @param array  $offsets  Relative term positions. Array of integers.
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct($terms = null, $offsets = null, $field = null)
+    {
+        $this->_slop = 0;
+
+        if (is_array($terms)) {
+            $this->_terms = array();
+            foreach ($terms as $termId => $termText) {
+                $this->_terms[$termId] = ($field !== null)? new Zend_Search_Lucene_Index_Term($termText, $field):
+                                                            new Zend_Search_Lucene_Index_Term($termText);
+            }
+        } else if ($terms === null) {
+            $this->_terms = array();
+        } else {
+            throw new Zend_Search_Lucene_Exception('terms argument must be array of strings or null');
+        }
+
+        if (is_array($offsets)) {
+            if (count($this->_terms) != count($offsets)) {
+                throw new Zend_Search_Lucene_Exception('terms and offsets arguments must have the same size.');
+            }
+            $this->_offsets = $offsets;
+        } else if ($offsets === null) {
+            $this->_offsets = array();
+            foreach ($this->_terms as $termId => $term) {
+                $position = count($this->_offsets);
+                $this->_offsets[$termId] = $position;
+            }
+        } else {
+            throw new Zend_Search_Lucene_Exception('offsets argument must be array of strings or null');
+        }
+    }
+
+    /**
+     * Set slop
+     *
+     * @param integer $slop
+     */
+    public function setSlop($slop)
+    {
+        $this->_slop = $slop;
+    }
+
+
+    /**
+     * Get slop
+     *
+     * @return integer
+     */
+    public function getSlop()
+    {
+        return $this->_slop;
+    }
+
+
+    /**
+     * Adds a term to the end of the query phrase.
+     * The relative position of the term is specified explicitly or the one immediately
+     * after the last term added.
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param integer $position
+     */
+    public function addTerm(Zend_Search_Lucene_Index_Term $term, $position = null) {
+        if ((count($this->_terms) != 0)&&(end($this->_terms)->field != $term->field)) {
+            throw new Zend_Search_Lucene_Exception('All phrase terms must be in the same field: ' .
+                                                   $term->field . ':' . $term->text);
+        }
+
+        $this->_terms[] = $term;
+        if ($position !== null) {
+            $this->_offsets[] = $position;
+        } else if (count($this->_offsets) != 0) {
+            $this->_offsets[] = end($this->_offsets) + 1;
+        } else {
+            $this->_offsets[] = 0;
+        }
+    }
+
+
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        if (count($this->_terms) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        } else if ($this->_terms[0]->field !== null) {
+            return $this;
+        } else {
+            $query = new Zend_Search_Lucene_Search_Query_Boolean();
+            $query->setBoost($this->getBoost());
+
+            foreach ($index->getFieldNames(true) as $fieldName) {
+                $subquery = new Zend_Search_Lucene_Search_Query_Phrase();
+                $subquery->setSlop($this->getSlop());
+
+                foreach ($this->_terms as $termId => $term) {
+                    $qualifiedTerm = new Zend_Search_Lucene_Index_Term($term->text, $fieldName);
+
+                    $subquery->addTerm($qualifiedTerm, $this->_offsets[$termId]);
+                }
+
+                $query->addSubquery($subquery);
+            }
+
+            return $query;
+        }
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        // Check, that index contains all phrase terms
+        foreach ($this->_terms as $term) {
+            if (!$index->hasTerm($term)) {
+                return new Zend_Search_Lucene_Search_Query_Empty();
+            }
+        }
+
+        if (count($this->_terms) == 1) {
+            // It's one term query
+            $optimizedQuery = new Zend_Search_Lucene_Search_Query_Term(reset($this->_terms));
+            $optimizedQuery->setBoost($this->getBoost());
+
+            return $optimizedQuery;
+        }
+
+        if (count($this->_terms) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        }
+
+
+        return $this;
+    }
+
+    /**
+     * Returns query term
+     *
+     * @return array
+     */
+    public function getTerms()
+    {
+        return $this->_terms;
+    }
+
+
+    /**
+     * Set weight for specified term
+     *
+     * @param integer $num
+     * @param Zend_Search_Lucene_Search_Weight_Term $weight
+     */
+    public function setWeight($num, $weight)
+    {
+        $this->_weights[$num] = $weight;
+    }
+
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        $this->_weight = new Zend_Search_Lucene_Search_Weight_Phrase($this, $reader);
+        return $this->_weight;
+    }
+
+
+    /**
+     * Score calculator for exact phrase queries (terms sequence is fixed)
+     *
+     * @param integer $docId
+     * @return float
+     */
+    public function _exactPhraseFreq($docId)
+    {
+        $freq = 0;
+
+        // Term Id with lowest cardinality
+        $lowCardTermId = null;
+
+        // Calculate $lowCardTermId
+        foreach ($this->_terms as $termId => $term) {
+            if ($lowCardTermId === null ||
+                count($this->_termsPositions[$termId][$docId]) <
+                count($this->_termsPositions[$lowCardTermId][$docId]) ) {
+                    $lowCardTermId = $termId;
+                }
+        }
+
+        // Walk through positions of the term with lowest cardinality
+        foreach ($this->_termsPositions[$lowCardTermId][$docId] as $lowCardPos) {
+            // We expect phrase to be found
+            $freq++;
+
+            // Walk through other terms
+            foreach ($this->_terms as $termId => $term) {
+                if ($termId != $lowCardTermId) {
+                    $expectedPosition = $lowCardPos +
+                                            ($this->_offsets[$termId] -
+                                             $this->_offsets[$lowCardTermId]);
+
+                    if (!in_array($expectedPosition, $this->_termsPositions[$termId][$docId])) {
+                        $freq--;  // Phrase wasn't found.
+                        break;
+                    }
+                }
+            }
+        }
+
+        return $freq;
+    }
+
+    /**
+     * Score calculator for sloppy phrase queries (terms sequence is fixed)
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function _sloppyPhraseFreq($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        $freq = 0;
+
+        $phraseQueue = array();
+        $phraseQueue[0] = array(); // empty phrase
+        $lastTerm = null;
+
+        // Walk through the terms to create phrases.
+        foreach ($this->_terms as $termId => $term) {
+            $queueSize = count($phraseQueue);
+            $firstPass = true;
+
+            // Walk through the term positions.
+            // Each term position produces a set of phrases.
+            foreach ($this->_termsPositions[$termId][$docId] as $termPosition ) {
+                if ($firstPass) {
+                    for ($count = 0; $count < $queueSize; $count++) {
+                        $phraseQueue[$count][$termId] = $termPosition;
+                    }
+                } else {
+                    for ($count = 0; $count < $queueSize; $count++) {
+                        if ($lastTerm !== null &&
+                            abs( $termPosition - $phraseQueue[$count][$lastTerm] -
+                                 ($this->_offsets[$termId] - $this->_offsets[$lastTerm])) > $this->_slop) {
+                            continue;
+                        }
+
+                        $newPhraseId = count($phraseQueue);
+                        $phraseQueue[$newPhraseId]          = $phraseQueue[$count];
+                        $phraseQueue[$newPhraseId][$termId] = $termPosition;
+                    }
+
+                }
+
+                $firstPass = false;
+            }
+            $lastTerm = $termId;
+        }
+
+
+        foreach ($phraseQueue as $phrasePos) {
+            $minDistance = null;
+
+            for ($shift = -$this->_slop; $shift <= $this->_slop; $shift++) {
+                $distance = 0;
+                $start = reset($phrasePos) - reset($this->_offsets) + $shift;
+
+                foreach ($this->_terms as $termId => $term) {
+                    $distance += abs($phrasePos[$termId] - $this->_offsets[$termId] - $start);
+
+                    if($distance > $this->_slop) {
+                        break;
+                    }
+                }
+
+                if ($minDistance === null || $distance < $minDistance) {
+                    $minDistance = $distance;
+                }
+            }
+
+            if ($minDistance <= $this->_slop) {
+                $freq += $reader->getSimilarity()->sloppyFreq($minDistance);
+            }
+        }
+
+        return $freq;
+    }
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        $this->_resVector = null;
+
+        if (count($this->_terms) == 0) {
+            $this->_resVector = array();
+        }
+
+        $resVectors      = array();
+        $resVectorsSizes = array();
+        $resVectorsIds   = array(); // is used to prevent arrays comparison
+        foreach ($this->_terms as $termId => $term) {
+            $resVectors[]      = array_flip($reader->termDocs($term));
+            $resVectorsSizes[] = count(end($resVectors));
+            $resVectorsIds[]   = $termId;
+
+            $this->_termsPositions[$termId] = $reader->termPositions($term);
+        }
+        // sort resvectors in order of subquery cardinality increasing
+        array_multisort($resVectorsSizes, SORT_ASC, SORT_NUMERIC,
+                        $resVectorsIds,   SORT_ASC, SORT_NUMERIC,
+                        $resVectors);
+
+        foreach ($resVectors as $nextResVector) {
+            if($this->_resVector === null) {
+                $this->_resVector = $nextResVector;
+            } else {
+                //$this->_resVector = array_intersect_key($this->_resVector, $nextResVector);
+
+                /**
+                 * This code is used as workaround for array_intersect_key() slowness problem.
+                 */
+                $updatedVector = array();
+                foreach ($this->_resVector as $id => $value) {
+                    if (isset($nextResVector[$id])) {
+                        $updatedVector[$id] = $value;
+                    }
+                }
+                $this->_resVector = $updatedVector;
+            }
+
+            if (count($this->_resVector) == 0) {
+                // Empty result set, we don't need to check other terms
+                break;
+            }
+        }
+
+        // ksort($this->_resVector, SORT_NUMERIC);
+        // Docs are returned ordered. Used algorithm doesn't change elements order.
+
+        // Initialize weight if it's not done yet
+        $this->_initWeight($reader);
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     */
+    public function matchedDocs()
+    {
+        return $this->_resVector;
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        if (isset($this->_resVector[$docId])) {
+            if ($this->_slop == 0) {
+                $freq = $this->_exactPhraseFreq($docId);
+            } else {
+                $freq = $this->_sloppyPhraseFreq($docId, $reader);
+            }
+
+            if ($freq != 0) {
+                $tf = $reader->getSimilarity()->tf($freq);
+                $weight = $this->_weight->getValue();
+                $norm = $reader->norm($docId, reset($this->_terms)->field);
+
+                return $tf * $weight * $norm * $this->getBoost();
+            }
+
+            // Included in result, but culculated freq is zero
+            return 0;
+        } else {
+            return 0;
+        }
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     */
+    public function getQueryTerms()
+    {
+        return $this->_terms;
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        $words = array();
+        foreach ($this->_terms as $term) {
+            $words[] = $term->text;
+        }
+
+        $highlighter->highlight($words);
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        // It's used only for query visualisation, so we don't care about characters escaping
+        if (isset($this->_terms[0]) && $this->_terms[0]->field !== null) {
+            $query = $this->_terms[0]->field . ':';
+        } else {
+               $query = '';
+        }
+
+        $query .= '"';
+
+        foreach ($this->_terms as $id => $term) {
+            if ($id != 0) {
+                $query .= ' ';
+            }
+            $query .= $term->text;
+        }
+
+        $query .= '"';
+
+        if ($this->_slop != 0) {
+            $query .= '~' . $this->_slop;
+        }
+
+        if ($this->getBoost() != 1) {
+            $query .= '^' . round($this->getBoost(), 4);
+        }
+
+        return $query;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing.php b/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing.php
new file mode 100644 (file)
index 0000000..4a0f4aa
--- /dev/null
@@ -0,0 +1,134 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: Preprocessing.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+\r
+/**\r
+ * Zend_Search_Lucene_Search_Query\r
+ */\r
+require_once 'Zend/Search/Lucene/Search/Query.php';\r
+\r
+/**\r
+ * Zend_Search_Lucene_Search_Weight\r
+ */\r
+require_once 'Zend/Search/Lucene/Search/Weight.php';\r
+\r
+\r
+/**\r
+ * It's an internal abstract class intended to finalize ase a query processing after query parsing.\r
+ * This type of query is not actually involved into query execution.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @internal\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+abstract class Zend_Search_Lucene_Search_Query_Preprocessing extends Zend_Search_Lucene_Search_Query\r
+{\r
+    /**\r
+     * Matched terms.\r
+     *\r
+     * Matched terms list.\r
+     * It's filled during rewrite operation and may be used for search result highlighting\r
+     *\r
+     * Array of Zend_Search_Lucene_Index_Term objects\r
+     *\r
+     * @var array\r
+     */\r
+    protected $_matches = null;\r
+\r
+    /**\r
+     * Optimize query in the context of specified index\r
+     *\r
+     * @param Zend_Search_Lucene_Interface $index\r
+     * @return Zend_Search_Lucene_Search_Query\r
+     */\r
+    public function optimize(Zend_Search_Lucene_Interface $index)\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('This query is not intended to be executed.');\r
+    }\r
+\r
+    /**\r
+     * Constructs an appropriate Weight implementation for this query.\r
+     *\r
+     * @param Zend_Search_Lucene_Interface $reader\r
+     * @return Zend_Search_Lucene_Search_Weight\r
+     */\r
+    public function createWeight(Zend_Search_Lucene_Interface $reader)\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('This query is not intended to be executed.');\r
+    }\r
+\r
+    /**\r
+     * Execute query in context of index reader\r
+     * It also initializes necessary internal structures\r
+     *\r
+     * @param Zend_Search_Lucene_Interface $reader\r
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter\r
+     */\r
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('This query is not intended to be executed.');\r
+    }\r
+\r
+    /**\r
+     * Get document ids likely matching the query\r
+     *\r
+     * It's an array with document ids as keys (performance considerations)\r
+     *\r
+     * @return array\r
+     */\r
+    public function matchedDocs()\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('This query is not intended to be executed.');\r
+    }\r
+\r
+    /**\r
+     * Score specified document\r
+     *\r
+     * @param integer $docId\r
+     * @param Zend_Search_Lucene_Interface $reader\r
+     * @return float\r
+     */\r
+    public function score($docId, Zend_Search_Lucene_Interface $reader)\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('This query is not intended to be executed.');\r
+    }\r
+\r
+    /**\r
+     * Return query terms\r
+     *\r
+     * @return array\r
+     */\r
+    public function getQueryTerms()\r
+    {\r
+        require_once 'Zend/Search/Lucene/Exception.php';\r
+        throw new Zend_Search_Lucene_Exception('Rewrite operation has to be done before retrieving query terms.');\r
+    }\r
+}\r
+\r
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Fuzzy.php b/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Fuzzy.php
new file mode 100644 (file)
index 0000000..eed96cc
--- /dev/null
@@ -0,0 +1,287 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: Fuzzy.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+\r
+/** Zend_Search_Lucene_Search_Query_Processing */\r
+require_once 'Zend/Search/Lucene/Search/Query/Preprocessing.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Phrase */\r
+require_once 'Zend/Search/Lucene/Search/Query/Phrase.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Insignificant */\r
+require_once 'Zend/Search/Lucene/Search/Query/Insignificant.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Empty */\r
+require_once 'Zend/Search/Lucene/Search/Query/Empty.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Term */\r
+require_once 'Zend/Search/Lucene/Search/Query/Term.php';\r
+\r
+/** Zend_Search_Lucene_Index_Term */\r
+require_once 'Zend/Search/Lucene/Index/Term.php';\r
+\r
+\r
+/**\r
+ * It's an internal abstract class intended to finalize ase a query processing after query parsing.\r
+ * This type of query is not actually involved into query execution.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @internal\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+class Zend_Search_Lucene_Search_Query_Preprocessing_Fuzzy extends Zend_Search_Lucene_Search_Query_Preprocessing\r
+{\r
+    /**\r
+     * word (query parser lexeme) to find.\r
+     *\r
+     * @var string\r
+     */\r
+    private $_word;\r
+\r
+    /**\r
+     * Word encoding (field name is always provided using UTF-8 encoding since it may be retrieved from index).\r
+     *\r
+     * @var string\r
+     */\r
+    private $_encoding;\r
+\r
+\r
+    /**\r
+     * Field name.\r
+     *\r
+     * @var string\r
+     */\r
+    private $_field;\r
+\r
+    /**\r
+     * A value between 0 and 1 to set the required similarity\r
+     *  between the query term and the matching terms. For example, for a\r
+     *  _minimumSimilarity of 0.5 a term of the same length\r
+     *  as the query term is considered similar to the query term if the edit distance\r
+     *  between both terms is less than length(term)*0.5\r
+     *\r
+     * @var float\r
+     */\r
+    private $_minimumSimilarity;\r
+\r
+    /**\r
+     * Class constructor.  Create a new preprocessing object for prase query.\r
+     *\r
+     * @param string $word       Non-tokenized word (query parser lexeme) to search.\r
+     * @param string $encoding   Word encoding.\r
+     * @param string $fieldName  Field name.\r
+     * @param float  $minimumSimilarity minimum similarity\r
+     */\r
+    public function __construct($word, $encoding, $fieldName, $minimumSimilarity)\r
+    {\r
+        $this->_word     = $word;\r
+        $this->_encoding = $encoding;\r
+        $this->_field    = $fieldName;\r
+        $this->_minimumSimilarity = $minimumSimilarity;\r
+    }\r
+\r
+    /**\r
+     * Re-write query into primitive queries in the context of specified index\r
+     *\r
+     * @param Zend_Search_Lucene_Interface $index\r
+     * @return Zend_Search_Lucene_Search_Query\r
+     */\r
+    public function rewrite(Zend_Search_Lucene_Interface $index)\r
+    {\r
+        if ($this->_field === null) {\r
+            $query = new Zend_Search_Lucene_Search_Query_Boolean();\r
+\r
+            $hasInsignificantSubqueries = false;\r
+\r
+            if (Zend_Search_Lucene::getDefaultSearchField() === null) {\r
+                $searchFields = $index->getFieldNames(true);\r
+            } else {\r
+                $searchFields = array(Zend_Search_Lucene::getDefaultSearchField());\r
+            }\r
+\r
+            foreach ($searchFields as $fieldName) {\r
+                $subquery = new Zend_Search_Lucene_Search_Query_Preprocessing_Fuzzy($this->_word,\r
+                                                                                    $this->_encoding,\r
+                                                                                    $fieldName,\r
+                                                                                    $this->_minimumSimilarity);\r
+\r
+                $rewrittenSubquery = $subquery->rewrite($index);\r
+\r
+                if ( !($rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Insignificant  ||\r
+                       $rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Empty) ) {\r
+                    $query->addSubquery($rewrittenSubquery);\r
+                }\r
+\r
+                if ($rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Insignificant) {\r
+                       $hasInsignificantSubqueries = true;\r
+                }\r
+            }\r
+\r
+            $subqueries = $query->getSubqueries();\r
+\r
+            if (count($subqueries) == 0) {\r
+               $this->_matches = array();\r
+                if ($hasInsignificantSubqueries) {\r
+                    return new Zend_Search_Lucene_Search_Query_Insignificant();\r
+                } else {\r
+                    return new Zend_Search_Lucene_Search_Query_Empty();\r
+                }\r
+            }\r
+\r
+            if (count($subqueries) == 1) {\r
+               $query = reset($subqueries);\r
+            }\r
+\r
+            $query->setBoost($this->getBoost());\r
+\r
+            $this->_matches = $query->getQueryTerms();\r
+            return $query;\r
+        }\r
+\r
+        // -------------------------------------\r
+        // Recognize exact term matching (it corresponds to Keyword fields stored in the index)\r
+        // encoding is not used since we expect binary matching\r
+        $term = new Zend_Search_Lucene_Index_Term($this->_word, $this->_field);\r
+        if ($index->hasTerm($term)) {\r
+            $query = new Zend_Search_Lucene_Search_Query_Fuzzy($term, $this->_minimumSimilarity);\r
+            $query->setBoost($this->getBoost());\r
+\r
+            // Get rewritten query. Important! It also fills terms matching container.\r
+            $rewrittenQuery = $query->rewrite($index);\r
+            $this->_matches = $query->getQueryTerms();\r
+\r
+            return $rewrittenQuery;\r
+        }\r
+\r
+\r
+        // -------------------------------------\r
+        // Recognize wildcard queries\r
+\r
+        /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */\r
+        if (@preg_match('/\pL/u', 'a') == 1) {\r
+               $subPatterns = preg_split('/[*?]/u', iconv($this->_encoding, 'UTF-8', $this->_word));\r
+        } else {\r
+               $subPatterns = preg_split('/[*?]/', $this->_word);\r
+        }\r
+        if (count($subPatterns) > 1) {\r
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';\r
+            throw new Zend_Search_Lucene_Search_QueryParserException('Fuzzy search doesn\'t support wildcards (except within Keyword fields).');\r
+        }\r
+\r
+\r
+        // -------------------------------------\r
+        // Recognize one-term multi-term and "insignificant" queries\r
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding);\r
+\r
+        if (count($tokens) == 0) {\r
+               $this->_matches = array();\r
+            return new Zend_Search_Lucene_Search_Query_Insignificant();\r
+        }\r
+\r
+        if (count($tokens) == 1) {\r
+            $term  = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field);\r
+            $query = new Zend_Search_Lucene_Search_Query_Fuzzy($term, $this->_minimumSimilarity);\r
+            $query->setBoost($this->getBoost());\r
+\r
+            // Get rewritten query. Important! It also fills terms matching container.\r
+            $rewrittenQuery = $query->rewrite($index);\r
+            $this->_matches = $query->getQueryTerms();\r
+\r
+            return $rewrittenQuery;\r
+        }\r
+\r
+        // Word is tokenized into several tokens\r
+        require_once 'Zend/Search/Lucene/Search/QueryParserException.php';\r
+        throw new Zend_Search_Lucene_Search_QueryParserException('Fuzzy search is supported only for non-multiple word terms');\r
+    }\r
+\r
+    /**\r
+     * Query specific matches highlighting\r
+     *\r
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)\r
+     */\r
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)\r
+    {\r
+       /** Skip fields detection. We don't need it, since we expect all fields presented in the HTML body and don't differentiate them */\r
+\r
+       /** Skip exact term matching recognition, keyword fields highlighting is not supported */\r
+\r
+        // -------------------------------------\r
+        // Recognize wildcard queries\r
+\r
+        /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */\r
+        if (@preg_match('/\pL/u', 'a') == 1) {\r
+            $subPatterns = preg_split('/[*?]/u', iconv($this->_encoding, 'UTF-8', $this->_word));\r
+        } else {\r
+            $subPatterns = preg_split('/[*?]/', $this->_word);\r
+        }\r
+        if (count($subPatterns) > 1) {\r
+            // Do nothing\r
+            return;\r
+        }\r
+\r
+        // -------------------------------------\r
+        // Recognize one-term multi-term and "insignificant" queries\r
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding);\r
+        if (count($tokens) == 0) {\r
+            // Do nothing\r
+            return;\r
+        }\r
+        if (count($tokens) == 1) {\r
+            $term  = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field);\r
+            $query = new Zend_Search_Lucene_Search_Query_Fuzzy($term, $this->_minimumSimilarity);\r
+\r
+            $query->_highlightMatches($highlighter);\r
+            return;\r
+        }\r
+\r
+        // Word is tokenized into several tokens\r
+        // But fuzzy search is supported only for non-multiple word terms\r
+        // Do nothing\r
+    }\r
+\r
+    /**\r
+     * Print a query\r
+     *\r
+     * @return string\r
+     */\r
+    public function __toString()\r
+    {\r
+        // It's used only for query visualisation, so we don't care about characters escaping\r
+        if ($this->_field !== null) {\r
+            $query = $this->_field . ':';\r
+        } else {\r
+            $query = '';\r
+        }\r
+\r
+        $query .= $this->_word;\r
+\r
+        if ($this->getBoost() != 1) {\r
+            $query .= '^' . round($this->getBoost(), 4);\r
+        }\r
+\r
+        return $query;\r
+    }\r
+}\r
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php b/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php
new file mode 100644 (file)
index 0000000..6ec236c
--- /dev/null
@@ -0,0 +1,274 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: Phrase.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+\r
+/** Zend_Search_Lucene_Search_Query_Processing */\r
+require_once 'Zend/Search/Lucene/Search/Query/Preprocessing.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Phrase */\r
+require_once 'Zend/Search/Lucene/Search/Query/Phrase.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Insignificant */\r
+require_once 'Zend/Search/Lucene/Search/Query/Insignificant.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Empty */\r
+require_once 'Zend/Search/Lucene/Search/Query/Empty.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Term */\r
+require_once 'Zend/Search/Lucene/Search/Query/Term.php';\r
+\r
+/** Zend_Search_Lucene_Index_Term */\r
+require_once 'Zend/Search/Lucene/Index/Term.php';\r
+\r
+\r
+/**\r
+ * It's an internal abstract class intended to finalize ase a query processing after query parsing.\r
+ * This type of query is not actually involved into query execution.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @internal\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+class Zend_Search_Lucene_Search_Query_Preprocessing_Phrase extends Zend_Search_Lucene_Search_Query_Preprocessing\r
+{\r
+    /**\r
+     * Phrase to find.\r
+     *\r
+     * @var string\r
+     */\r
+    private $_phrase;\r
+\r
+    /**\r
+     * Phrase encoding (field name is always provided using UTF-8 encoding since it may be retrieved from index).\r
+     *\r
+     * @var string\r
+     */\r
+    private $_phraseEncoding;\r
+\r
+\r
+    /**\r
+     * Field name.\r
+     *\r
+     * @var string\r
+     */\r
+    private $_field;\r
+\r
+    /**\r
+     * Sets the number of other words permitted between words in query phrase.\r
+     * If zero, then this is an exact phrase search.  For larger values this works\r
+     * like a WITHIN or NEAR operator.\r
+     *\r
+     * The slop is in fact an edit-distance, where the units correspond to\r
+     * moves of terms in the query phrase out of position.  For example, to switch\r
+     * the order of two words requires two moves (the first move places the words\r
+     * atop one another), so to permit re-orderings of phrases, the slop must be\r
+     * at least two.\r
+     * More exact matches are scored higher than sloppier matches, thus search\r
+     * results are sorted by exactness.\r
+     *\r
+     * The slop is zero by default, requiring exact matches.\r
+     *\r
+     * @var integer\r
+     */\r
+    private $_slop;\r
+\r
+    /**\r
+     * Class constructor.  Create a new preprocessing object for prase query.\r
+     *\r
+     * @param string $phrase          Phrase to search.\r
+     * @param string $phraseEncoding  Phrase encoding.\r
+     * @param string $fieldName       Field name.\r
+     */\r
+    public function __construct($phrase, $phraseEncoding, $fieldName)\r
+    {\r
+       $this->_phrase         = $phrase;\r
+       $this->_phraseEncoding = $phraseEncoding;\r
+       $this->_field          = $fieldName;\r
+    }\r
+\r
+    /**\r
+     * Set slop\r
+     *\r
+     * @param integer $slop\r
+     */\r
+    public function setSlop($slop)\r
+    {\r
+        $this->_slop = $slop;\r
+    }\r
+\r
+\r
+    /**\r
+     * Get slop\r
+     *\r
+     * @return integer\r
+     */\r
+    public function getSlop()\r
+    {\r
+        return $this->_slop;\r
+    }\r
+\r
+    /**\r
+     * Re-write query into primitive queries in the context of specified index\r
+     *\r
+     * @param Zend_Search_Lucene_Interface $index\r
+     * @return Zend_Search_Lucene_Search_Query\r
+     */\r
+    public function rewrite(Zend_Search_Lucene_Interface $index)\r
+    {\r
+// Allow to use wildcards within phrases\r
+// They are either removed by text analyzer or used as a part of keyword for keyword fields\r
+//\r
+//        if (strpos($this->_phrase, '?') !== false || strpos($this->_phrase, '*') !== false) {\r
+//            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';\r
+//            throw new Zend_Search_Lucene_Search_QueryParserException('Wildcards are only allowed in a single terms.');\r
+//        }\r
+\r
+       // Split query into subqueries if field name is not specified\r
+       if ($this->_field === null) {\r
+               $query = new Zend_Search_Lucene_Search_Query_Boolean();\r
+            $query->setBoost($this->getBoost());\r
+\r
+            if (Zend_Search_Lucene::getDefaultSearchField() === null) {\r
+                $searchFields = $index->getFieldNames(true);\r
+            } else {\r
+                $searchFields = array(Zend_Search_Lucene::getDefaultSearchField());\r
+            }\r
+\r
+            foreach ($searchFields as $fieldName) {\r
+                $subquery = new Zend_Search_Lucene_Search_Query_Preprocessing_Phrase($this->_phrase,\r
+                                                                                     $this->_phraseEncoding,\r
+                                                                                     $fieldName);\r
+                $subquery->setSlop($this->getSlop());\r
+\r
+                $query->addSubquery($subquery->rewrite($index));\r
+            }\r
+\r
+            $this->_matches = $query->getQueryTerms();\r
+            return $query;\r
+       }\r
+\r
+       // Recognize exact term matching (it corresponds to Keyword fields stored in the index)\r
+       // encoding is not used since we expect binary matching\r
+       $term = new Zend_Search_Lucene_Index_Term($this->_phrase, $this->_field);\r
+       if ($index->hasTerm($term)) {\r
+            $query = new Zend_Search_Lucene_Search_Query_Term($term);\r
+               $query->setBoost($this->getBoost());\r
+\r
+               $this->_matches = $query->getQueryTerms();\r
+               return $query;\r
+       }\r
+\r
+\r
+       // tokenize phrase using current analyzer and process it as a phrase query\r
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_phrase, $this->_phraseEncoding);\r
+\r
+        if (count($tokens) == 0) {\r
+               $this->_matches = array();\r
+            return new Zend_Search_Lucene_Search_Query_Insignificant();\r
+        }\r
+\r
+        if (count($tokens) == 1) {\r
+            $term  = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field);\r
+            $query = new Zend_Search_Lucene_Search_Query_Term($term);\r
+            $query->setBoost($this->getBoost());\r
+\r
+            $this->_matches = $query->getQueryTerms();\r
+            return $query;\r
+        }\r
+\r
+        //It's non-trivial phrase query\r
+        $position = -1;\r
+        $query = new Zend_Search_Lucene_Search_Query_Phrase();\r
+        foreach ($tokens as $token) {\r
+            $position += $token->getPositionIncrement();\r
+            $term = new Zend_Search_Lucene_Index_Term($token->getTermText(), $this->_field);\r
+            $query->addTerm($term, $position);\r
+            $query->setSlop($this->getSlop());\r
+        }\r
+        $this->_matches = $query->getQueryTerms();\r
+        return $query;\r
+    }\r
+\r
+    /**\r
+     * Query specific matches highlighting\r
+     *\r
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)\r
+     */\r
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)\r
+    {\r
+       /** Skip fields detection. We don't need it, since we expect all fields presented in the HTML body and don't differentiate them */\r
+\r
+        /** Skip exact term matching recognition, keyword fields highlighting is not supported */\r
+\r
+        /** Skip wildcard queries recognition. Supported wildcards are removed by text analyzer */\r
+\r
+        // tokenize phrase using current analyzer and process it as a phrase query\r
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_phrase, $this->_phraseEncoding);\r
+\r
+        if (count($tokens) == 0) {\r
+            // Do nothing\r
+            return;\r
+        }\r
+\r
+        if (count($tokens) == 1) {\r
+            $highlighter->highlight($tokens[0]->getTermText());\r
+            return;\r
+        }\r
+\r
+        //It's non-trivial phrase query\r
+        $words = array();\r
+        foreach ($tokens as $token) {\r
+            $words[] = $token->getTermText();\r
+        }\r
+        $highlighter->highlight($words);\r
+    }\r
+\r
+    /**\r
+     * Print a query\r
+     *\r
+     * @return string\r
+     */\r
+    public function __toString()\r
+    {\r
+        // It's used only for query visualisation, so we don't care about characters escaping\r
+        if ($this->_field !== null) {\r
+            $query = $this->_field . ':';\r
+        } else {\r
+               $query = '';\r
+        }\r
+\r
+        $query .= '"' . $this->_phrase . '"';\r
+\r
+        if ($this->_slop != 0) {\r
+            $query .= '~' . $this->_slop;\r
+        }\r
+\r
+        if ($this->getBoost() != 1) {\r
+            $query .= '^' . round($this->getBoost(), 4);\r
+        }\r
+\r
+        return $query;\r
+    }\r
+}\r
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Term.php b/inc/lib/Zend/Search/Lucene/Search/Query/Preprocessing/Term.php
new file mode 100644 (file)
index 0000000..720a892
--- /dev/null
@@ -0,0 +1,335 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: Term.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+\r
+/** Zend_Search_Lucene_Search_Query_Processing */\r
+require_once 'Zend/Search/Lucene/Search/Query/Preprocessing.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Phrase */\r
+require_once 'Zend/Search/Lucene/Search/Query/Phrase.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Insignificant */\r
+require_once 'Zend/Search/Lucene/Search/Query/Insignificant.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Empty */\r
+require_once 'Zend/Search/Lucene/Search/Query/Empty.php';\r
+\r
+/** Zend_Search_Lucene_Search_Query_Term */\r
+require_once 'Zend/Search/Lucene/Search/Query/Term.php';\r
+\r
+/** Zend_Search_Lucene_Index_Term */\r
+require_once 'Zend/Search/Lucene/Index/Term.php';\r
+\r
+\r
+/**\r
+ * It's an internal abstract class intended to finalize ase a query processing after query parsing.\r
+ * This type of query is not actually involved into query execution.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Search\r
+ * @internal\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+class Zend_Search_Lucene_Search_Query_Preprocessing_Term extends Zend_Search_Lucene_Search_Query_Preprocessing\r
+{\r
+    /**\r
+     * word (query parser lexeme) to find.\r
+     *\r
+     * @var string\r
+     */\r
+    private $_word;\r
+\r
+    /**\r
+     * Word encoding (field name is always provided using UTF-8 encoding since it may be retrieved from index).\r
+     *\r
+     * @var string\r
+     */\r
+    private $_encoding;\r
+\r
+\r
+    /**\r
+     * Field name.\r
+     *\r
+     * @var string\r
+     */\r
+    private $_field;\r
+\r
+    /**\r
+     * Class constructor.  Create a new preprocessing object for prase query.\r
+     *\r
+     * @param string $word       Non-tokenized word (query parser lexeme) to search.\r
+     * @param string $encoding   Word encoding.\r
+     * @param string $fieldName  Field name.\r
+     */\r
+    public function __construct($word, $encoding, $fieldName)\r
+    {\r
+        $this->_word     = $word;\r
+        $this->_encoding = $encoding;\r
+        $this->_field    = $fieldName;\r
+    }\r
+\r
+    /**\r
+     * Re-write query into primitive queries in the context of specified index\r
+     *\r
+     * @param Zend_Search_Lucene_Interface $index\r
+     * @return Zend_Search_Lucene_Search_Query\r
+     */\r
+    public function rewrite(Zend_Search_Lucene_Interface $index)\r
+    {\r
+        if ($this->_field === null) {\r
+            $query = new Zend_Search_Lucene_Search_Query_MultiTerm();\r
+            $query->setBoost($this->getBoost());\r
+\r
+            $hasInsignificantSubqueries = false;\r
+\r
+            if (Zend_Search_Lucene::getDefaultSearchField() === null) {\r
+               $searchFields = $index->getFieldNames(true);\r
+            } else {\r
+               $searchFields = array(Zend_Search_Lucene::getDefaultSearchField());\r
+            }\r
+\r
+            foreach ($searchFields as $fieldName) {\r
+               $subquery = new Zend_Search_Lucene_Search_Query_Preprocessing_Term($this->_word,\r
+                                                                                   $this->_encoding,\r
+                                                                                   $fieldName);\r
+                $rewrittenSubquery = $subquery->rewrite($index);\r
+                foreach ($rewrittenSubquery->getQueryTerms() as $term) {\r
+                       $query->addTerm($term);\r
+                }\r
+\r
+                if ($rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Insignificant) {\r
+                       $hasInsignificantSubqueries = true;\r
+                }\r
+            }\r
+\r
+            if (count($query->getTerms()) == 0) {\r
+               $this->_matches = array();\r
+               if ($hasInsignificantSubqueries) {\r
+                       return new Zend_Search_Lucene_Search_Query_Insignificant();\r
+               } else {\r
+                       return new Zend_Search_Lucene_Search_Query_Empty();\r
+               }\r
+            }\r
+\r
+            $this->_matches = $query->getQueryTerms();\r
+            return $query;\r
+        }\r
+\r
+        // -------------------------------------\r
+        // Recognize exact term matching (it corresponds to Keyword fields stored in the index)\r
+        // encoding is not used since we expect binary matching\r
+        $term = new Zend_Search_Lucene_Index_Term($this->_word, $this->_field);\r
+        if ($index->hasTerm($term)) {\r
+            $query = new Zend_Search_Lucene_Search_Query_Term($term);\r
+            $query->setBoost($this->getBoost());\r
+\r
+            $this->_matches = $query->getQueryTerms();\r
+            return $query;\r
+        }\r
+\r
+\r
+        // -------------------------------------\r
+        // Recognize wildcard queries\r
+\r
+        /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */\r
+        if (@preg_match('/\pL/u', 'a') == 1) {\r
+               $word = iconv($this->_encoding, 'UTF-8', $this->_word);\r
+               $wildcardsPattern = '/[*?]/u';\r
+               $subPatternsEncoding = 'UTF-8';\r
+        } else {\r
+               $word = $this->_word;\r
+               $wildcardsPattern = '/[*?]/';\r
+            $subPatternsEncoding = $this->_encoding;\r
+        }\r
+\r
+        $subPatterns = preg_split($wildcardsPattern, $word, -1, PREG_SPLIT_OFFSET_CAPTURE);\r
+\r
+        if (count($subPatterns) > 1) {\r
+               // Wildcard query is recognized\r
+\r
+               $pattern = '';\r
+\r
+            foreach ($subPatterns as $id => $subPattern) {\r
+               // Append corresponding wildcard character to the pattern before each sub-pattern (except first)\r
+                if ($id != 0) {\r
+                       $pattern .= $word[ $subPattern[1] - 1 ];\r
+                }\r
+\r
+                // Check if each subputtern is a single word in terms of current analyzer\r
+                $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($subPattern[0], $subPatternsEncoding);\r
+                if (count($tokens) > 1) {\r
+                    require_once 'Zend/Search/Lucene/Search/QueryParserException.php';\r
+                    throw new Zend_Search_Lucene_Search_QueryParserException('Wildcard search is supported only for non-multiple word terms');\r
+                }\r
+                foreach ($tokens as $token) {\r
+                    $pattern .= $token->getTermText();\r
+                }\r
+            }\r
+\r
+            $term  = new Zend_Search_Lucene_Index_Term($pattern, $this->_field);\r
+            $query = new Zend_Search_Lucene_Search_Query_Wildcard($term);\r
+            $query->setBoost($this->getBoost());\r
+\r
+            // Get rewritten query. Important! It also fills terms matching container.\r
+            $rewrittenQuery = $query->rewrite($index);\r
+            $this->_matches = $query->getQueryTerms();\r
+\r
+            return $rewrittenQuery;\r
+        }\r
+\r
+\r
+        // -------------------------------------\r
+        // Recognize one-term multi-term and "insignificant" queries\r
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding);\r
+\r
+        if (count($tokens) == 0) {\r
+               $this->_matches = array();\r
+            return new Zend_Search_Lucene_Search_Query_Insignificant();\r
+        }\r
+\r
+        if (count($tokens) == 1) {\r
+            $term  = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field);\r
+            $query = new Zend_Search_Lucene_Search_Query_Term($term);\r
+            $query->setBoost($this->getBoost());\r
+\r
+            $this->_matches = $query->getQueryTerms();\r
+            return $query;\r
+        }\r
+\r
+        //It's not insignificant or one term query\r
+        $query = new Zend_Search_Lucene_Search_Query_MultiTerm();\r
+\r
+        /**\r
+         * @todo Process $token->getPositionIncrement() to support stemming, synonyms and other\r
+         * analizer design features\r
+         */\r
+        foreach ($tokens as $token) {\r
+            $term = new Zend_Search_Lucene_Index_Term($token->getTermText(), $this->_field);\r
+            $query->addTerm($term, true); // all subterms are required\r
+        }\r
+\r
+        $query->setBoost($this->getBoost());\r
+\r
+        $this->_matches = $query->getQueryTerms();\r
+        return $query;\r
+    }\r
+\r
+    /**\r
+     * Query specific matches highlighting\r
+     *\r
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)\r
+     */\r
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)\r
+    {\r
+        /** Skip fields detection. We don't need it, since we expect all fields presented in the HTML body and don't differentiate them */\r
+\r
+        /** Skip exact term matching recognition, keyword fields highlighting is not supported */\r
+\r
+        // -------------------------------------\r
+        // Recognize wildcard queries\r
+        /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */\r
+        if (@preg_match('/\pL/u', 'a') == 1) {\r
+            $word = iconv($this->_encoding, 'UTF-8', $this->_word);\r
+            $wildcardsPattern = '/[*?]/u';\r
+            $subPatternsEncoding = 'UTF-8';\r
+        } else {\r
+            $word = $this->_word;\r
+            $wildcardsPattern = '/[*?]/';\r
+            $subPatternsEncoding = $this->_encoding;\r
+        }\r
+        $subPatterns = preg_split($wildcardsPattern, $word, -1, PREG_SPLIT_OFFSET_CAPTURE);\r
+        if (count($subPatterns) > 1) {\r
+            // Wildcard query is recognized\r
+\r
+            $pattern = '';\r
+\r
+            foreach ($subPatterns as $id => $subPattern) {\r
+                // Append corresponding wildcard character to the pattern before each sub-pattern (except first)\r
+                if ($id != 0) {\r
+                    $pattern .= $word[ $subPattern[1] - 1 ];\r
+                }\r
+\r
+                // Check if each subputtern is a single word in terms of current analyzer\r
+                $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($subPattern[0], $subPatternsEncoding);\r
+                if (count($tokens) > 1) {\r
+                       // Do nothing (nothing is highlighted)\r
+                    return;\r
+                }\r
+                foreach ($tokens as $token) {\r
+                    $pattern .= $token->getTermText();\r
+                }\r
+            }\r
+\r
+            $term  = new Zend_Search_Lucene_Index_Term($pattern, $this->_field);\r
+            $query = new Zend_Search_Lucene_Search_Query_Wildcard($term);\r
+\r
+            $query->_highlightMatches($highlighter);\r
+            return;\r
+        }\r
+\r
+        // -------------------------------------\r
+        // Recognize one-term multi-term and "insignificant" queries\r
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding);\r
+\r
+        if (count($tokens) == 0) {\r
+            // Do nothing\r
+            return;\r
+        }\r
+\r
+        if (count($tokens) == 1) {\r
+            $highlighter->highlight($tokens[0]->getTermText());\r
+            return;\r
+        }\r
+\r
+        //It's not insignificant or one term query\r
+        $words = array();\r
+        foreach ($tokens as $token) {\r
+            $words[] = $token->getTermText();\r
+        }\r
+        $highlighter->highlight($words);\r
+    }\r
+\r
+    /**\r
+     * Print a query\r
+     *\r
+     * @return string\r
+     */\r
+    public function __toString()\r
+    {\r
+        // It's used only for query visualisation, so we don't care about characters escaping\r
+        if ($this->_field !== null) {\r
+            $query = $this->_field . ':';\r
+        } else {\r
+            $query = '';\r
+        }\r
+\r
+        $query .= $this->_word;\r
+\r
+        if ($this->getBoost() != 1) {\r
+            $query .= '^' . round($this->getBoost(), 4);\r
+        }\r
+\r
+        return $query;\r
+    }\r
+}\r
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Range.php b/inc/lib/Zend/Search/Lucene/Search/Query/Range.php
new file mode 100644 (file)
index 0000000..9e158e0
--- /dev/null
@@ -0,0 +1,377 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Range.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Query_MultiTerm */
+require_once 'Zend/Search/Lucene/Search/Query/MultiTerm.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Range extends Zend_Search_Lucene_Search_Query
+{
+    /**
+     * Lower term.
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_lowerTerm;
+
+    /**
+     * Upper term.
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_upperTerm;
+
+
+    /**
+     * Search field
+     *
+     * @var string
+     */
+    private $_field;
+
+    /**
+     * Inclusive
+     *
+     * @var boolean
+     */
+    private $_inclusive;
+
+    /**
+     * Matched terms.
+     *
+     * Matched terms list.
+     * It's filled during the search (rewrite operation) and may be used for search result
+     * post-processing
+     *
+     * Array of Zend_Search_Lucene_Index_Term objects
+     *
+     * @var array
+     */
+    private $_matches = null;
+
+
+    /**
+     * Zend_Search_Lucene_Search_Query_Range constructor.
+     *
+     * @param Zend_Search_Lucene_Index_Term|null $lowerTerm
+     * @param Zend_Search_Lucene_Index_Term|null $upperTerm
+     * @param boolean $inclusive
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct($lowerTerm, $upperTerm, $inclusive)
+    {
+        if ($lowerTerm === null  &&  $upperTerm === null) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('At least one term must be non-null');
+        }
+        if ($lowerTerm !== null  &&  $upperTerm !== null  &&  $lowerTerm->field != $upperTerm->field) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Both terms must be for the same field');
+        }
+
+        $this->_field     = ($lowerTerm !== null)? $lowerTerm->field : $upperTerm->field;
+        $this->_lowerTerm = $lowerTerm;
+        $this->_upperTerm = $upperTerm;
+        $this->_inclusive = $inclusive;
+    }
+
+    /**
+     * Get query field name
+     *
+     * @return string|null
+     */
+    public function getField()
+    {
+        return $this->_field;
+    }
+
+    /**
+     * Get lower term
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function getLowerTerm()
+    {
+        return $this->_lowerTerm;
+    }
+
+    /**
+     * Get upper term
+     *
+     * @return Zend_Search_Lucene_Index_Term|null
+     */
+    public function getUpperTerm()
+    {
+        return $this->_upperTerm;
+    }
+
+    /**
+     * Get upper term
+     *
+     * @return boolean
+     */
+    public function isInclusive()
+    {
+        return $this->_inclusive;
+    }
+
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        $this->_matches = array();
+
+        if ($this->_field === null) {
+            // Search through all fields
+            $fields = $index->getFieldNames(true /* indexed fields list */);
+        } else {
+            $fields = array($this->_field);
+        }
+
+        $maxTerms = Zend_Search_Lucene::getTermsPerQueryLimit();
+        foreach ($fields as $field) {
+            $index->resetTermsStream();
+
+            if ($this->_lowerTerm !== null) {
+                $lowerTerm = new Zend_Search_Lucene_Index_Term($this->_lowerTerm->text, $field);
+
+                $index->skipTo($lowerTerm);
+
+                if (!$this->_inclusive  &&
+                    $index->currentTerm() == $lowerTerm) {
+                    // Skip lower term
+                    $index->nextTerm();
+                }
+            } else {
+                $index->skipTo(new Zend_Search_Lucene_Index_Term('', $field));
+            }
+
+
+            if ($this->_upperTerm !== null) {
+                // Walk up to the upper term
+                $upperTerm = new Zend_Search_Lucene_Index_Term($this->_upperTerm->text, $field);
+
+                while ($index->currentTerm() !== null          &&
+                       $index->currentTerm()->field == $field  &&
+//                       $index->currentTerm()->text  <  $upperTerm->text
+// WEZ: this actually needs to be lexigraphically searched
+                       strcmp($index->currentTerm()->text, $upperTerm->text) < 0
+                                          ) {
+                    $this->_matches[] = $index->currentTerm();
+
+                    if ($maxTerms != 0  &&  count($this->_matches) > $maxTerms) {
+                        require_once 'Zend/Search/Lucene/Exception.php';
+                        throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
+                    }
+
+                    $index->nextTerm();
+                }
+
+                if ($this->_inclusive  &&  $index->currentTerm() == $upperTerm) {
+                    // Include upper term into result
+                    $this->_matches[] = $upperTerm;
+                }
+            } else {
+                // Walk up to the end of field data
+                while ($index->currentTerm() !== null  &&  $index->currentTerm()->field == $field) {
+                    $this->_matches[] = $index->currentTerm();
+
+                    if ($maxTerms != 0  &&  count($this->_matches) > $maxTerms) {
+                        require_once 'Zend/Search/Lucene/Exception.php';
+                        throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
+                    }
+
+                    $index->nextTerm();
+                }
+            }
+
+            $index->closeTermsStream();
+        }
+
+        if (count($this->_matches) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        } else if (count($this->_matches) == 1) {
+            return new Zend_Search_Lucene_Search_Query_Term(reset($this->_matches));
+        } else {
+            $rewrittenQuery = new Zend_Search_Lucene_Search_Query_MultiTerm();
+
+            foreach ($this->_matches as $matchedTerm) {
+                $rewrittenQuery->addTerm($matchedTerm);
+            }
+
+            return $rewrittenQuery;
+        }
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Range query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function getQueryTerms()
+    {
+        if ($this->_matches === null) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Search or rewrite operations have to be performed before.');
+        }
+
+        return $this->_matches;
+    }
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Range query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Range query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function matchedDocs()
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Range query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        require_once 'Zend/Search/Lucene/Exception.php';
+        throw new Zend_Search_Lucene_Exception('Range query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        $words = array();
+
+        $docBody = $highlighter->getDocument()->getFieldUtf8Value('body');
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($docBody, 'UTF-8');
+
+        $lowerTermText = ($this->_lowerTerm !== null)? $this->_lowerTerm->text : null;
+        $upperTermText = ($this->_upperTerm !== null)? $this->_upperTerm->text : null;
+
+        if ($this->_inclusive) {
+               foreach ($tokens as $token) {
+                   $termText = $token->getTermText();
+                   if (($lowerTermText == null  ||  $lowerTermText <= $termText)  &&
+                       ($upperTermText == null  ||  $termText <= $upperTermText)) {
+                       $words[] = $termText;
+                   }
+               }
+        } else {
+            foreach ($tokens as $token) {
+                $termText = $token->getTermText();
+                if (($lowerTermText == null  ||  $lowerTermText < $termText)  &&
+                    ($upperTermText == null  ||  $termText < $upperTermText)) {
+                    $words[] = $termText;
+                }
+            }
+        }
+
+        $highlighter->highlight($words);
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        // It's used only for query visualisation, so we don't care about characters escaping
+        return (($this->_field === null)? '' : $this->_field . ':')
+             . (($this->_inclusive)? '[' : '{')
+             . (($this->_lowerTerm !== null)?  $this->_lowerTerm->text : 'null')
+             . ' TO '
+             . (($this->_upperTerm !== null)?  $this->_upperTerm->text : 'null')
+             . (($this->_inclusive)? ']' : '}')
+             . (($this->getBoost() != 1)? '^' . round($this->getBoost(), 4) : '');
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Term.php b/inc/lib/Zend/Search/Lucene/Search/Query/Term.php
new file mode 100644 (file)
index 0000000..2df9f86
--- /dev/null
@@ -0,0 +1,227 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Term.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Weight_Term */
+require_once 'Zend/Search/Lucene/Search/Weight/Term.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Term extends Zend_Search_Lucene_Search_Query
+{
+    /**
+     * Term to find.
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_term;
+
+    /**
+     * Documents vector.
+     *
+     * @var array
+     */
+    private $_docVector = null;
+
+    /**
+     * Term freqs vector.
+     * array(docId => freq, ...)
+     *
+     * @var array
+     */
+    private $_termFreqs;
+
+
+    /**
+     * Zend_Search_Lucene_Search_Query_Term constructor
+     *
+     * @param Zend_Search_Lucene_Index_Term $term
+     * @param boolean $sign
+     */
+    public function __construct(Zend_Search_Lucene_Index_Term $term)
+    {
+        $this->_term = $term;
+    }
+
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        if ($this->_term->field != null) {
+            return $this;
+        } else {
+            $query = new Zend_Search_Lucene_Search_Query_MultiTerm();
+            $query->setBoost($this->getBoost());
+
+            foreach ($index->getFieldNames(true) as $fieldName) {
+                $term = new Zend_Search_Lucene_Index_Term($this->_term->text, $fieldName);
+
+                $query->addTerm($term);
+            }
+
+            return $query->rewrite($index);
+        }
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        // Check, that index contains specified term
+        if (!$index->hasTerm($this->_term)) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        }
+
+        return $this;
+    }
+
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        $this->_weight = new Zend_Search_Lucene_Search_Weight_Term($this->_term, $this, $reader);
+        return $this->_weight;
+    }
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        $this->_docVector = array_flip($reader->termDocs($this->_term, $docsFilter));
+        $this->_termFreqs = $reader->termFreqs($this->_term, $docsFilter);
+
+        // Initialize weight if it's not done yet
+        $this->_initWeight($reader);
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     */
+    public function matchedDocs()
+    {
+        return $this->_docVector;
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        if (isset($this->_docVector[$docId])) {
+            return $reader->getSimilarity()->tf($this->_termFreqs[$docId]) *
+                   $this->_weight->getValue() *
+                   $reader->norm($docId, $this->_term->field) *
+                   $this->getBoost();
+        } else {
+            return 0;
+        }
+    }
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     */
+    public function getQueryTerms()
+    {
+        return array($this->_term);
+    }
+
+    /**
+     * Return query term
+     *
+     * @return Zend_Search_Lucene_Index_Term
+     */
+    public function getTerm()
+    {
+        return $this->_term;
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+       $highlighter->highlight($this->_term->text);
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        // It's used only for query visualisation, so we don't care about characters escaping
+        if ($this->_term->field !== null) {
+               $query = $this->_term->field . ':';
+        } else {
+               $query = '';
+        }
+
+        $query .= $this->_term->text;
+
+        if ($this->getBoost() != 1) {
+            $query = $query . '^' . round($this->getBoost(), 4);
+        }
+
+        return $query;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Query/Wildcard.php b/inc/lib/Zend/Search/Lucene/Search/Query/Wildcard.php
new file mode 100644 (file)
index 0000000..a1bf9b8
--- /dev/null
@@ -0,0 +1,351 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Wildcard.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Search_Query */
+require_once 'Zend/Search/Lucene/Search/Query.php';
+
+/** Zend_Search_Lucene_Search_Query_MultiTerm */
+require_once 'Zend/Search/Lucene/Search/Query/MultiTerm.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Query_Wildcard extends Zend_Search_Lucene_Search_Query
+{
+    /**
+     * Search pattern.
+     *
+     * Field has to be fully specified or has to be null
+     * Text may contain '*' or '?' symbols
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_pattern;
+
+    /**
+     * Matched terms.
+     *
+     * Matched terms list.
+     * It's filled during the search (rewrite operation) and may be used for search result
+     * post-processing
+     *
+     * Array of Zend_Search_Lucene_Index_Term objects
+     *
+     * @var array
+     */
+    private $_matches = null;
+
+    /**
+     * Minimum term prefix length (number of minimum non-wildcard characters)
+     *
+     * @var integer
+     */
+    private static $_minPrefixLength = 3;
+
+    /**
+     * Zend_Search_Lucene_Search_Query_Wildcard constructor.
+     *
+     * @param Zend_Search_Lucene_Index_Term $pattern
+     */
+    public function __construct(Zend_Search_Lucene_Index_Term $pattern)
+    {
+        $this->_pattern = $pattern;
+    }
+
+    /**
+     * Get minimum prefix length
+     *
+     * @return integer
+     */
+    public static function getMinPrefixLength()
+    {
+       return self::$_minPrefixLength;
+    }
+
+    /**
+     * Set minimum prefix length
+     *
+     * @param integer $minPrefixLength
+     */
+    public static function setMinPrefixLength($minPrefixLength)
+    {
+       self::$_minPrefixLength = $minPrefixLength;
+    }
+
+    /**
+     * Get terms prefix
+     *
+     * @param string $word
+     * @return string
+     */
+    private static function _getPrefix($word)
+    {
+        $questionMarkPosition = strpos($word, '?');
+        $astrericPosition     = strpos($word, '*');
+
+        if ($questionMarkPosition !== false) {
+            if ($astrericPosition !== false) {
+                return substr($word, 0, min($questionMarkPosition, $astrericPosition));
+            }
+
+            return substr($word, 0, $questionMarkPosition);
+        } else if ($astrericPosition !== false) {
+            return substr($word, 0, $astrericPosition);
+        }
+
+        return $word;
+    }
+
+    /**
+     * Re-write query into primitive queries in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function rewrite(Zend_Search_Lucene_Interface $index)
+    {
+        $this->_matches = array();
+
+        if ($this->_pattern->field === null) {
+            // Search through all fields
+            $fields = $index->getFieldNames(true /* indexed fields list */);
+        } else {
+            $fields = array($this->_pattern->field);
+        }
+
+        $prefix          = self::_getPrefix($this->_pattern->text);
+        $prefixLength    = strlen($prefix);
+        $matchExpression = '/^' . str_replace(array('\\?', '\\*'), array('.', '.*') , preg_quote($this->_pattern->text, '/')) . '$/';
+
+        if ($prefixLength < self::$_minPrefixLength) {
+               throw new Zend_Search_Lucene_Exception('At least ' . self::$_minPrefixLength . ' non-wildcard characters are required at the beginning of pattern.');
+        }
+
+        /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */
+        if (@preg_match('/\pL/u', 'a') == 1) {
+            // PCRE unicode support is turned on
+            // add Unicode modifier to the match expression
+            $matchExpression .= 'u';
+        }
+
+        $maxTerms = Zend_Search_Lucene::getTermsPerQueryLimit();
+        foreach ($fields as $field) {
+            $index->resetTermsStream();
+
+            if ($prefix != '') {
+                $index->skipTo(new Zend_Search_Lucene_Index_Term($prefix, $field));
+
+                while ($index->currentTerm() !== null          &&
+                       $index->currentTerm()->field == $field  &&
+                       substr($index->currentTerm()->text, 0, $prefixLength) == $prefix) {
+                    if (preg_match($matchExpression, $index->currentTerm()->text) === 1) {
+                        $this->_matches[] = $index->currentTerm();
+
+                        if ($maxTerms != 0  &&  count($this->_matches) > $maxTerms) {
+                               throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
+                        }
+                    }
+
+                    $index->nextTerm();
+                }
+            } else {
+                $index->skipTo(new Zend_Search_Lucene_Index_Term('', $field));
+
+                while ($index->currentTerm() !== null  &&  $index->currentTerm()->field == $field) {
+                    if (preg_match($matchExpression, $index->currentTerm()->text) === 1) {
+                        $this->_matches[] = $index->currentTerm();
+
+                        if ($maxTerms != 0  &&  count($this->_matches) > $maxTerms) {
+                            throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
+                        }
+                    }
+
+                    $index->nextTerm();
+                }
+            }
+
+            $index->closeTermsStream();
+        }
+
+        if (count($this->_matches) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Empty();
+        } else if (count($this->_matches) == 1) {
+            return new Zend_Search_Lucene_Search_Query_Term(reset($this->_matches));
+        } else {
+            $rewrittenQuery = new Zend_Search_Lucene_Search_Query_MultiTerm();
+
+            foreach ($this->_matches as $matchedTerm) {
+                $rewrittenQuery->addTerm($matchedTerm);
+            }
+
+            return $rewrittenQuery;
+        }
+    }
+
+    /**
+     * Optimize query in the context of specified index
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function optimize(Zend_Search_Lucene_Interface $index)
+    {
+        throw new Zend_Search_Lucene_Exception('Wildcard query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+
+    /**
+     * Returns query pattern
+     *
+     * @return Zend_Search_Lucene_Index_Term
+     */
+    public function getPattern()
+    {
+        return $this->_pattern;
+    }
+
+
+    /**
+     * Return query terms
+     *
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function getQueryTerms()
+    {
+        if ($this->_matches === null) {
+            throw new Zend_Search_Lucene_Exception('Search has to be performed first to get matched terms');
+        }
+
+        return $this->_matches;
+    }
+
+    /**
+     * Constructs an appropriate Weight implementation for this query.
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return Zend_Search_Lucene_Search_Weight
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function createWeight(Zend_Search_Lucene_Interface $reader)
+    {
+        throw new Zend_Search_Lucene_Exception('Wildcard query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+
+    /**
+     * Execute query in context of index reader
+     * It also initializes necessary internal structures
+     *
+     * @param Zend_Search_Lucene_Interface $reader
+     * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
+    {
+        throw new Zend_Search_Lucene_Exception('Wildcard query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Get document ids likely matching the query
+     *
+     * It's an array with document ids as keys (performance considerations)
+     *
+     * @return array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function matchedDocs()
+    {
+        throw new Zend_Search_Lucene_Exception('Wildcard query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Score specified document
+     *
+     * @param integer $docId
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return float
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function score($docId, Zend_Search_Lucene_Interface $reader)
+    {
+        throw new Zend_Search_Lucene_Exception('Wildcard query should not be directly used for search. Use $query->rewrite($index)');
+    }
+
+    /**
+     * Query specific matches highlighting
+     *
+     * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter  Highlighter object (also contains doc for highlighting)
+     */
+    protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
+    {
+        $words = array();
+
+        $matchExpression = '/^' . str_replace(array('\\?', '\\*'), array('.', '.*') , preg_quote($this->_pattern->text, '/')) . '$/';
+        if (@preg_match('/\pL/u', 'a') == 1) {
+            // PCRE unicode support is turned on
+            // add Unicode modifier to the match expression
+            $matchExpression .= 'u';
+        }
+
+        $docBody = $highlighter->getDocument()->getFieldUtf8Value('body');
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($docBody, 'UTF-8');
+        foreach ($tokens as $token) {
+            if (preg_match($matchExpression, $token->getTermText()) === 1) {
+                $words[] = $token->getTermText();
+            }
+        }
+
+        $highlighter->highlight($words);
+    }
+
+    /**
+     * Print a query
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        // It's used only for query visualisation, so we don't care about characters escaping
+        if ($this->_pattern->field !== null) {
+            $query = $this->_pattern->field . ':';
+        } else {
+            $query = '';
+        }
+
+        $query .= $this->_pattern->text;
+
+        if ($this->getBoost() != 1) {
+            $query = $query . '^' . round($this->getBoost(), 4);
+        }
+
+        return $query;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryEntry.php b/inc/lib/Zend/Search/Lucene/Search/QueryEntry.php
new file mode 100644 (file)
index 0000000..d25f7e8
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: QueryEntry.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Index_Term */
+require_once 'Zend/Search/Lucene/Index/Term.php';
+
+/** Zend_Search_Lucene_Search_QueryEntry_Term */
+require_once 'Zend/Search/Lucene/Search/QueryEntry/Term.php';
+
+/** Zend_Search_Lucene_Search_QueryEntry_Phrase */
+require_once 'Zend/Search/Lucene/Search/QueryEntry/Phrase.php';
+
+/** Zend_Search_Lucene_Search_QueryEntry_Subquery */
+require_once 'Zend/Search/Lucene/Search/QueryEntry/Subquery.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Search_QueryEntry
+{
+    /**
+     * Query entry boost factor
+     *
+     * @var float
+     */
+    protected $_boost = 1.0;
+
+
+    /**
+     * Process modifier ('~')
+     *
+     * @param mixed $parameter
+     */
+    abstract public function processFuzzyProximityModifier($parameter = null);
+
+
+    /**
+     * Transform entry to a subquery
+     *
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    abstract public function getQuery($encoding);
+
+    /**
+     * Boost query entry
+     *
+     * @param float $boostFactor
+     */
+    public function boost($boostFactor)
+    {
+        $this->_boost *= $boostFactor;
+    }
+
+
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryEntry/Phrase.php b/inc/lib/Zend/Search/Lucene/Search/QueryEntry/Phrase.php
new file mode 100644 (file)
index 0000000..dc8ec39
--- /dev/null
@@ -0,0 +1,120 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Phrase.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Index_Term */
+require_once 'Zend/Search/Lucene/Index/Term.php';
+
+/** Zend_Search_Lucene_Search_QueryEntry */
+require_once 'Zend/Search/Lucene/Search/QueryEntry.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryEntry_Phrase extends Zend_Search_Lucene_Search_QueryEntry
+{
+    /**
+     * Phrase value
+     *
+     * @var string
+     */
+    private $_phrase;
+
+    /**
+     * Field
+     *
+     * @var string|null
+     */
+    private $_field;
+
+
+    /**
+     * Proximity phrase query
+     *
+     * @var boolean
+     */
+    private $_proximityQuery = false;
+
+    /**
+     * Words distance, used for proximiti queries
+     *
+     * @var integer
+     */
+    private $_wordsDistance = 0;
+
+
+    /**
+     * Object constractor
+     *
+     * @param string $phrase
+     * @param string $field
+     */
+    public function __construct($phrase, $field)
+    {
+        $this->_phrase = $phrase;
+        $this->_field  = $field;
+    }
+
+    /**
+     * Process modifier ('~')
+     *
+     * @param mixed $parameter
+     */
+    public function processFuzzyProximityModifier($parameter = null)
+    {
+        $this->_proximityQuery = true;
+
+        if ($parameter !== null) {
+            $this->_wordsDistance = $parameter;
+        }
+    }
+
+    /**
+     * Transform entry to a subquery
+     *
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Search_Query
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function getQuery($encoding)
+    {
+       $query = new Zend_Search_Lucene_Search_Query_Preprocessing_Phrase($this->_phrase,
+                                                                         $encoding,
+                                                                         ($this->_field !== null)?
+                                                                             iconv($encoding, 'UTF-8', $this->_field) :
+                                                                             null);
+
+        if ($this->_proximityQuery) {
+            $query->setSlop($this->_wordsDistance);
+        }
+
+        $query->setBoost($this->_boost);
+
+        return $query;
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryEntry/Subquery.php b/inc/lib/Zend/Search/Lucene/Search/QueryEntry/Subquery.php
new file mode 100644 (file)
index 0000000..f0fec48
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Subquery.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Index_Term */
+require_once 'Zend/Search/Lucene/Index/Term.php';
+
+/** Zend_Search_Lucene_Search_QueryEntry */
+require_once 'Zend/Search/Lucene/Search/QueryEntry.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryEntry_Subquery extends Zend_Search_Lucene_Search_QueryEntry
+{
+    /**
+     * Query
+     *
+     * @var Zend_Search_Lucene_Search_Query
+     */
+    private $_query;
+
+    /**
+     * Object constractor
+     *
+     * @param Zend_Search_Lucene_Search_Query $query
+     */
+    public function __construct(Zend_Search_Lucene_Search_Query $query)
+    {
+        $this->_query = $query;
+    }
+
+    /**
+     * Process modifier ('~')
+     *
+     * @param mixed $parameter
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function processFuzzyProximityModifier($parameter = null)
+    {
+        require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+        throw new Zend_Search_Lucene_Search_QueryParserException('\'~\' sign must follow term or phrase');
+    }
+
+
+    /**
+     * Transform entry to a subquery
+     *
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function getQuery($encoding)
+    {
+        $this->_query->setBoost($this->_boost);
+
+        return $this->_query;
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryEntry/Term.php b/inc/lib/Zend/Search/Lucene/Search/QueryEntry/Term.php
new file mode 100644 (file)
index 0000000..8001637
--- /dev/null
@@ -0,0 +1,130 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Term.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Index_Term */
+require_once 'Zend/Search/Lucene/Index/Term.php';
+
+/** Zend_Search_Lucene_Search_QueryEntry */
+require_once 'Zend/Search/Lucene/Search/QueryEntry.php';
+
+/** Zend_Search_Lucene_Analysis_Analyzer */
+require_once 'Zend/Search/Lucene/Analysis/Analyzer.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryEntry_Term extends Zend_Search_Lucene_Search_QueryEntry
+{
+    /**
+     * Term value
+     *
+     * @var string
+     */
+    private $_term;
+
+    /**
+     * Field
+     *
+     * @var string|null
+     */
+    private $_field;
+
+
+    /**
+     * Fuzzy search query
+     *
+     * @var boolean
+     */
+    private $_fuzzyQuery = false;
+
+    /**
+     * Similarity
+     *
+     * @var float
+     */
+    private $_similarity = 1.;
+
+
+    /**
+     * Object constractor
+     *
+     * @param string $term
+     * @param string $field
+     */
+    public function __construct($term, $field)
+    {
+        $this->_term  = $term;
+        $this->_field = $field;
+    }
+
+    /**
+     * Process modifier ('~')
+     *
+     * @param mixed $parameter
+     */
+    public function processFuzzyProximityModifier($parameter = null)
+    {
+        $this->_fuzzyQuery = true;
+
+        if ($parameter !== null) {
+            $this->_similarity = $parameter;
+        } else {
+            $this->_similarity = Zend_Search_Lucene_Search_Query_Fuzzy::DEFAULT_MIN_SIMILARITY;
+        }
+    }
+
+    /**
+     * Transform entry to a subquery
+     *
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Search_Query
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function getQuery($encoding)
+    {
+       if ($this->_fuzzyQuery) {
+               $query = new Zend_Search_Lucene_Search_Query_Preprocessing_Fuzzy($this->_term,
+                                                                                $encoding,
+                                                                                ($this->_field !== null)?
+                                                                                  iconv($encoding, 'UTF-8', $this->_field) :
+                                                                                  null,
+                                                                                $this->_similarity
+                                                                                );
+            $query->setBoost($this->_boost);
+            return $query;
+       }
+
+
+       $query = new Zend_Search_Lucene_Search_Query_Preprocessing_Term($this->_term,
+                                                                       $encoding,
+                                                                       ($this->_field !== null)?
+                                                                              iconv($encoding, 'UTF-8', $this->_field) :
+                                                                              null
+                                                                        );
+       $query->setBoost($this->_boost);
+        return $query;
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryHit.php b/inc/lib/Zend/Search/Lucene/Search/QueryHit.php
new file mode 100644 (file)
index 0000000..ae3910a
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: QueryHit.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryHit
+{
+    /**
+     * Object handle of the index
+     * @var Zend_Search_Lucene_Interface
+     */
+    protected $_index = null;
+
+    /**
+     * Object handle of the document associated with this hit
+     * @var Zend_Search_Lucene_Document
+     */
+    protected $_document = null;
+
+    /**
+     * Number of the document in the index
+     * @var integer
+     */
+    public $id;
+
+    /**
+     * Score of the hit
+     * @var float
+     */
+    public $score;
+
+
+    /**
+     * Constructor - pass object handle of Zend_Search_Lucene_Interface index that produced
+     * the hit so the document can be retrieved easily from the hit.
+     *
+     * @param Zend_Search_Lucene_Interface $index
+     */
+
+    public function __construct(Zend_Search_Lucene_Interface $index)
+    {
+        $this->_index = new Zend_Search_Lucene_Proxy($index);
+    }
+
+
+    /**
+     * Convenience function for getting fields from the document
+     * associated with this hit.
+     *
+     * @param string $offset
+     * @return string
+     */
+    public function __get($offset)
+    {
+        return $this->getDocument()->getFieldValue($offset);
+    }
+
+
+    /**
+     * Return the document object for this hit
+     *
+     * @return Zend_Search_Lucene_Document
+     */
+    public function getDocument()
+    {
+        if (!$this->_document instanceof Zend_Search_Lucene_Document) {
+            $this->_document = $this->_index->getDocument($this->id);
+        }
+
+        return $this->_document;
+    }
+
+
+    /**
+     * Return the index object for this hit
+     *
+     * @return Zend_Search_Lucene_Interface
+     */
+    public function getIndex()
+    {
+        return $this->_index;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryLexer.php b/inc/lib/Zend/Search/Lucene/Search/QueryLexer.php
new file mode 100644 (file)
index 0000000..139d652
--- /dev/null
@@ -0,0 +1,510 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: QueryLexer.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_FSM */
+require_once 'Zend/Search/Lucene/FSM.php';
+
+/** Zend_Search_Lucene_Search_QueryParser */
+require_once 'Zend/Search/Lucene/Search/QueryToken.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryLexer extends Zend_Search_Lucene_FSM
+{
+    /** State Machine states */
+    const ST_WHITE_SPACE     = 0;
+    const ST_SYNT_LEXEME     = 1;
+    const ST_LEXEME          = 2;
+    const ST_QUOTED_LEXEME   = 3;
+    const ST_ESCAPED_CHAR    = 4;
+    const ST_ESCAPED_QCHAR   = 5;
+    const ST_LEXEME_MODIFIER = 6;
+    const ST_NUMBER          = 7;
+    const ST_MANTISSA        = 8;
+    const ST_ERROR           = 9;
+
+    /** Input symbols */
+    const IN_WHITE_SPACE     = 0;
+    const IN_SYNT_CHAR       = 1;
+    const IN_LEXEME_MODIFIER = 2;
+    const IN_ESCAPE_CHAR     = 3;
+    const IN_QUOTE           = 4;
+    const IN_DECIMAL_POINT   = 5;
+    const IN_ASCII_DIGIT     = 6;
+    const IN_CHAR            = 7;
+    const IN_MUTABLE_CHAR    = 8;
+
+    const QUERY_WHITE_SPACE_CHARS      = " \n\r\t";
+    const QUERY_SYNT_CHARS             = ':()[]{}!|&';
+    const QUERY_MUTABLE_CHARS          = '+-';
+    const QUERY_DOUBLECHARLEXEME_CHARS = '|&';
+    const QUERY_LEXEMEMODIFIER_CHARS   = '~^';
+    const QUERY_ASCIIDIGITS_CHARS      = '0123456789';
+
+    /**
+     * List of recognized lexemes
+     *
+     * @var array
+     */
+    private $_lexemes;
+
+    /**
+     * Query string (array of single- or non single-byte characters)
+     *
+     * @var array
+     */
+    private $_queryString;
+
+    /**
+     * Current position within a query string
+     * Used to create appropriate error messages
+     *
+     * @var integer
+     */
+    private $_queryStringPosition;
+
+    /**
+     * Recognized part of current lexeme
+     *
+     * @var string
+     */
+    private $_currentLexeme;
+
+    public function __construct()
+    {
+        parent::__construct( array(self::ST_WHITE_SPACE,
+                                   self::ST_SYNT_LEXEME,
+                                   self::ST_LEXEME,
+                                   self::ST_QUOTED_LEXEME,
+                                   self::ST_ESCAPED_CHAR,
+                                   self::ST_ESCAPED_QCHAR,
+                                   self::ST_LEXEME_MODIFIER,
+                                   self::ST_NUMBER,
+                                   self::ST_MANTISSA,
+                                   self::ST_ERROR),
+                             array(self::IN_WHITE_SPACE,
+                                   self::IN_SYNT_CHAR,
+                                   self::IN_MUTABLE_CHAR,
+                                   self::IN_LEXEME_MODIFIER,
+                                   self::IN_ESCAPE_CHAR,
+                                   self::IN_QUOTE,
+                                   self::IN_DECIMAL_POINT,
+                                   self::IN_ASCII_DIGIT,
+                                   self::IN_CHAR));
+
+
+        $lexemeModifierErrorAction    = new Zend_Search_Lucene_FSMAction($this, 'lexModifierErrException');
+        $quoteWithinLexemeErrorAction = new Zend_Search_Lucene_FSMAction($this, 'quoteWithinLexemeErrException');
+        $wrongNumberErrorAction       = new Zend_Search_Lucene_FSMAction($this, 'wrongNumberErrException');
+
+
+
+        $this->addRules(array( array(self::ST_WHITE_SPACE,   self::IN_WHITE_SPACE,     self::ST_WHITE_SPACE),
+                               array(self::ST_WHITE_SPACE,   self::IN_SYNT_CHAR,       self::ST_SYNT_LEXEME),
+                               array(self::ST_WHITE_SPACE,   self::IN_MUTABLE_CHAR,    self::ST_SYNT_LEXEME),
+                               array(self::ST_WHITE_SPACE,   self::IN_LEXEME_MODIFIER, self::ST_LEXEME_MODIFIER),
+                               array(self::ST_WHITE_SPACE,   self::IN_ESCAPE_CHAR,     self::ST_ESCAPED_CHAR),
+                               array(self::ST_WHITE_SPACE,   self::IN_QUOTE,           self::ST_QUOTED_LEXEME),
+                               array(self::ST_WHITE_SPACE,   self::IN_DECIMAL_POINT,   self::ST_LEXEME),
+                               array(self::ST_WHITE_SPACE,   self::IN_ASCII_DIGIT,     self::ST_LEXEME),
+                               array(self::ST_WHITE_SPACE,   self::IN_CHAR,            self::ST_LEXEME)
+                             ));
+        $this->addRules(array( array(self::ST_SYNT_LEXEME,   self::IN_WHITE_SPACE,     self::ST_WHITE_SPACE),
+                               array(self::ST_SYNT_LEXEME,   self::IN_SYNT_CHAR,       self::ST_SYNT_LEXEME),
+                               array(self::ST_SYNT_LEXEME,   self::IN_MUTABLE_CHAR,    self::ST_SYNT_LEXEME),
+                               array(self::ST_SYNT_LEXEME,   self::IN_LEXEME_MODIFIER, self::ST_LEXEME_MODIFIER),
+                               array(self::ST_SYNT_LEXEME,   self::IN_ESCAPE_CHAR,     self::ST_ESCAPED_CHAR),
+                               array(self::ST_SYNT_LEXEME,   self::IN_QUOTE,           self::ST_QUOTED_LEXEME),
+                               array(self::ST_SYNT_LEXEME,   self::IN_DECIMAL_POINT,   self::ST_LEXEME),
+                               array(self::ST_SYNT_LEXEME,   self::IN_ASCII_DIGIT,     self::ST_LEXEME),
+                               array(self::ST_SYNT_LEXEME,   self::IN_CHAR,            self::ST_LEXEME)
+                             ));
+        $this->addRules(array( array(self::ST_LEXEME,        self::IN_WHITE_SPACE,     self::ST_WHITE_SPACE),
+                               array(self::ST_LEXEME,        self::IN_SYNT_CHAR,       self::ST_SYNT_LEXEME),
+                               array(self::ST_LEXEME,        self::IN_MUTABLE_CHAR,    self::ST_LEXEME),
+                               array(self::ST_LEXEME,        self::IN_LEXEME_MODIFIER, self::ST_LEXEME_MODIFIER),
+                               array(self::ST_LEXEME,        self::IN_ESCAPE_CHAR,     self::ST_ESCAPED_CHAR),
+
+                               // IN_QUOTE     not allowed
+                               array(self::ST_LEXEME,        self::IN_QUOTE,           self::ST_ERROR, $quoteWithinLexemeErrorAction),
+
+                               array(self::ST_LEXEME,        self::IN_DECIMAL_POINT,   self::ST_LEXEME),
+                               array(self::ST_LEXEME,        self::IN_ASCII_DIGIT,     self::ST_LEXEME),
+                               array(self::ST_LEXEME,        self::IN_CHAR,            self::ST_LEXEME)
+                             ));
+        $this->addRules(array( array(self::ST_QUOTED_LEXEME, self::IN_WHITE_SPACE,     self::ST_QUOTED_LEXEME),
+                               array(self::ST_QUOTED_LEXEME, self::IN_SYNT_CHAR,       self::ST_QUOTED_LEXEME),
+                               array(self::ST_QUOTED_LEXEME, self::IN_MUTABLE_CHAR,    self::ST_QUOTED_LEXEME),
+                               array(self::ST_QUOTED_LEXEME, self::IN_LEXEME_MODIFIER, self::ST_QUOTED_LEXEME),
+                               array(self::ST_QUOTED_LEXEME, self::IN_ESCAPE_CHAR,     self::ST_ESCAPED_QCHAR),
+                               array(self::ST_QUOTED_LEXEME, self::IN_QUOTE,           self::ST_WHITE_SPACE),
+                               array(self::ST_QUOTED_LEXEME, self::IN_DECIMAL_POINT,   self::ST_QUOTED_LEXEME),
+                               array(self::ST_QUOTED_LEXEME, self::IN_ASCII_DIGIT,     self::ST_QUOTED_LEXEME),
+                               array(self::ST_QUOTED_LEXEME, self::IN_CHAR,            self::ST_QUOTED_LEXEME)
+                             ));
+        $this->addRules(array( array(self::ST_ESCAPED_CHAR,  self::IN_WHITE_SPACE,     self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_SYNT_CHAR,       self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_MUTABLE_CHAR,    self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_LEXEME_MODIFIER, self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_ESCAPE_CHAR,     self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_QUOTE,           self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_DECIMAL_POINT,   self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_ASCII_DIGIT,     self::ST_LEXEME),
+                               array(self::ST_ESCAPED_CHAR,  self::IN_CHAR,            self::ST_LEXEME)
+                             ));
+        $this->addRules(array( array(self::ST_ESCAPED_QCHAR, self::IN_WHITE_SPACE,     self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_SYNT_CHAR,       self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_MUTABLE_CHAR,    self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_LEXEME_MODIFIER, self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_ESCAPE_CHAR,     self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_QUOTE,           self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_DECIMAL_POINT,   self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_ASCII_DIGIT,     self::ST_QUOTED_LEXEME),
+                               array(self::ST_ESCAPED_QCHAR, self::IN_CHAR,            self::ST_QUOTED_LEXEME)
+                             ));
+        $this->addRules(array( array(self::ST_LEXEME_MODIFIER, self::IN_WHITE_SPACE,     self::ST_WHITE_SPACE),
+                               array(self::ST_LEXEME_MODIFIER, self::IN_SYNT_CHAR,       self::ST_SYNT_LEXEME),
+                               array(self::ST_LEXEME_MODIFIER, self::IN_MUTABLE_CHAR,    self::ST_SYNT_LEXEME),
+                               array(self::ST_LEXEME_MODIFIER, self::IN_LEXEME_MODIFIER, self::ST_LEXEME_MODIFIER),
+
+                               // IN_ESCAPE_CHAR       not allowed
+                               array(self::ST_LEXEME_MODIFIER, self::IN_ESCAPE_CHAR,     self::ST_ERROR, $lexemeModifierErrorAction),
+
+                               // IN_QUOTE             not allowed
+                               array(self::ST_LEXEME_MODIFIER, self::IN_QUOTE,           self::ST_ERROR, $lexemeModifierErrorAction),
+
+
+                               array(self::ST_LEXEME_MODIFIER, self::IN_DECIMAL_POINT,   self::ST_MANTISSA),
+                               array(self::ST_LEXEME_MODIFIER, self::IN_ASCII_DIGIT,     self::ST_NUMBER),
+
+                               // IN_CHAR              not allowed
+                               array(self::ST_LEXEME_MODIFIER, self::IN_CHAR,            self::ST_ERROR, $lexemeModifierErrorAction),
+                             ));
+        $this->addRules(array( array(self::ST_NUMBER, self::IN_WHITE_SPACE,     self::ST_WHITE_SPACE),
+                               array(self::ST_NUMBER, self::IN_SYNT_CHAR,       self::ST_SYNT_LEXEME),
+                               array(self::ST_NUMBER, self::IN_MUTABLE_CHAR,    self::ST_SYNT_LEXEME),
+                               array(self::ST_NUMBER, self::IN_LEXEME_MODIFIER, self::ST_LEXEME_MODIFIER),
+
+                               // IN_ESCAPE_CHAR       not allowed
+                               array(self::ST_NUMBER, self::IN_ESCAPE_CHAR,     self::ST_ERROR, $wrongNumberErrorAction),
+
+                               // IN_QUOTE             not allowed
+                               array(self::ST_NUMBER, self::IN_QUOTE,           self::ST_ERROR, $wrongNumberErrorAction),
+
+                               array(self::ST_NUMBER, self::IN_DECIMAL_POINT,   self::ST_MANTISSA),
+                               array(self::ST_NUMBER, self::IN_ASCII_DIGIT,     self::ST_NUMBER),
+
+                               // IN_CHAR              not allowed
+                               array(self::ST_NUMBER, self::IN_CHAR,            self::ST_ERROR, $wrongNumberErrorAction),
+                             ));
+        $this->addRules(array( array(self::ST_MANTISSA, self::IN_WHITE_SPACE,     self::ST_WHITE_SPACE),
+                               array(self::ST_MANTISSA, self::IN_SYNT_CHAR,       self::ST_SYNT_LEXEME),
+                               array(self::ST_MANTISSA, self::IN_MUTABLE_CHAR,    self::ST_SYNT_LEXEME),
+                               array(self::ST_MANTISSA, self::IN_LEXEME_MODIFIER, self::ST_LEXEME_MODIFIER),
+
+                               // IN_ESCAPE_CHAR       not allowed
+                               array(self::ST_MANTISSA, self::IN_ESCAPE_CHAR,     self::ST_ERROR, $wrongNumberErrorAction),
+
+                               // IN_QUOTE             not allowed
+                               array(self::ST_MANTISSA, self::IN_QUOTE,           self::ST_ERROR, $wrongNumberErrorAction),
+
+                               // IN_DECIMAL_POINT     not allowed
+                               array(self::ST_MANTISSA, self::IN_DECIMAL_POINT,   self::ST_ERROR, $wrongNumberErrorAction),
+
+                               array(self::ST_MANTISSA, self::IN_ASCII_DIGIT,     self::ST_MANTISSA),
+
+                               // IN_CHAR              not allowed
+                               array(self::ST_MANTISSA, self::IN_CHAR,            self::ST_ERROR, $wrongNumberErrorAction),
+                             ));
+
+
+        /** Actions */
+        $syntaxLexemeAction    = new Zend_Search_Lucene_FSMAction($this, 'addQuerySyntaxLexeme');
+        $lexemeModifierAction  = new Zend_Search_Lucene_FSMAction($this, 'addLexemeModifier');
+        $addLexemeAction       = new Zend_Search_Lucene_FSMAction($this, 'addLexeme');
+        $addQuotedLexemeAction = new Zend_Search_Lucene_FSMAction($this, 'addQuotedLexeme');
+        $addNumberLexemeAction = new Zend_Search_Lucene_FSMAction($this, 'addNumberLexeme');
+        $addLexemeCharAction   = new Zend_Search_Lucene_FSMAction($this, 'addLexemeChar');
+
+
+        /** Syntax lexeme */
+        $this->addEntryAction(self::ST_SYNT_LEXEME,  $syntaxLexemeAction);
+        // Two lexemes in succession
+        $this->addTransitionAction(self::ST_SYNT_LEXEME, self::ST_SYNT_LEXEME, $syntaxLexemeAction);
+
+
+        /** Lexeme */
+        $this->addEntryAction(self::ST_LEXEME,                       $addLexemeCharAction);
+        $this->addTransitionAction(self::ST_LEXEME, self::ST_LEXEME, $addLexemeCharAction);
+        // ST_ESCAPED_CHAR => ST_LEXEME transition is covered by ST_LEXEME entry action
+
+        $this->addTransitionAction(self::ST_LEXEME, self::ST_WHITE_SPACE,     $addLexemeAction);
+        $this->addTransitionAction(self::ST_LEXEME, self::ST_SYNT_LEXEME,     $addLexemeAction);
+        $this->addTransitionAction(self::ST_LEXEME, self::ST_QUOTED_LEXEME,   $addLexemeAction);
+        $this->addTransitionAction(self::ST_LEXEME, self::ST_LEXEME_MODIFIER, $addLexemeAction);
+        $this->addTransitionAction(self::ST_LEXEME, self::ST_NUMBER,          $addLexemeAction);
+        $this->addTransitionAction(self::ST_LEXEME, self::ST_MANTISSA,        $addLexemeAction);
+
+
+        /** Quoted lexeme */
+        // We don't need entry action (skeep quote)
+        $this->addTransitionAction(self::ST_QUOTED_LEXEME, self::ST_QUOTED_LEXEME, $addLexemeCharAction);
+        $this->addTransitionAction(self::ST_ESCAPED_QCHAR, self::ST_QUOTED_LEXEME, $addLexemeCharAction);
+        // Closing quote changes state to the ST_WHITE_SPACE   other states are not used
+        $this->addTransitionAction(self::ST_QUOTED_LEXEME, self::ST_WHITE_SPACE,   $addQuotedLexemeAction);
+
+
+        /** Lexeme modifier */
+        $this->addEntryAction(self::ST_LEXEME_MODIFIER, $lexemeModifierAction);
+
+
+        /** Number */
+        $this->addEntryAction(self::ST_NUMBER,                           $addLexemeCharAction);
+        $this->addEntryAction(self::ST_MANTISSA,                         $addLexemeCharAction);
+        $this->addTransitionAction(self::ST_NUMBER,   self::ST_NUMBER,   $addLexemeCharAction);
+        // ST_NUMBER => ST_MANTISSA transition is covered by ST_MANTISSA entry action
+        $this->addTransitionAction(self::ST_MANTISSA, self::ST_MANTISSA, $addLexemeCharAction);
+
+        $this->addTransitionAction(self::ST_NUMBER,   self::ST_WHITE_SPACE,     $addNumberLexemeAction);
+        $this->addTransitionAction(self::ST_NUMBER,   self::ST_SYNT_LEXEME,     $addNumberLexemeAction);
+        $this->addTransitionAction(self::ST_NUMBER,   self::ST_LEXEME_MODIFIER, $addNumberLexemeAction);
+        $this->addTransitionAction(self::ST_MANTISSA, self::ST_WHITE_SPACE,     $addNumberLexemeAction);
+        $this->addTransitionAction(self::ST_MANTISSA, self::ST_SYNT_LEXEME,     $addNumberLexemeAction);
+        $this->addTransitionAction(self::ST_MANTISSA, self::ST_LEXEME_MODIFIER, $addNumberLexemeAction);
+    }
+
+
+
+
+    /**
+     * Translate input char to an input symbol of state machine
+     *
+     * @param string $char
+     * @return integer
+     */
+    private function _translateInput($char)
+    {
+        if        (strpos(self::QUERY_WHITE_SPACE_CHARS,    $char) !== false) { return self::IN_WHITE_SPACE;
+        } else if (strpos(self::QUERY_SYNT_CHARS,           $char) !== false) { return self::IN_SYNT_CHAR;
+        } else if (strpos(self::QUERY_MUTABLE_CHARS,        $char) !== false) { return self::IN_MUTABLE_CHAR;
+        } else if (strpos(self::QUERY_LEXEMEMODIFIER_CHARS, $char) !== false) { return self::IN_LEXEME_MODIFIER;
+        } else if (strpos(self::QUERY_ASCIIDIGITS_CHARS,    $char) !== false) { return self::IN_ASCII_DIGIT;
+        } else if ($char === '"' )                                            { return self::IN_QUOTE;
+        } else if ($char === '.' )                                            { return self::IN_DECIMAL_POINT;
+        } else if ($char === '\\')                                            { return self::IN_ESCAPE_CHAR;
+        } else                                                                { return self::IN_CHAR;
+        }
+    }
+
+
+    /**
+     * This method is used to tokenize query string into lexemes
+     *
+     * @param string $inputString
+     * @param string $encoding
+     * @return array
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function tokenize($inputString, $encoding)
+    {
+        $this->reset();
+
+        $this->_lexemes     = array();
+        $this->_queryString = array();
+
+        if (PHP_OS == 'AIX' && $encoding == '') {
+            $encoding = 'ISO8859-1';
+        }
+        $strLength = iconv_strlen($inputString, $encoding);
+
+        // Workaround for iconv_substr bug
+        $inputString .= ' ';
+
+        for ($count = 0; $count < $strLength; $count++) {
+            $this->_queryString[$count] = iconv_substr($inputString, $count, 1, $encoding);
+        }
+
+        for ($this->_queryStringPosition = 0;
+             $this->_queryStringPosition < count($this->_queryString);
+             $this->_queryStringPosition++) {
+            $this->process($this->_translateInput($this->_queryString[$this->_queryStringPosition]));
+        }
+
+        $this->process(self::IN_WHITE_SPACE);
+
+        if ($this->getState() != self::ST_WHITE_SPACE) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Unexpected end of query');
+        }
+
+        $this->_queryString = null;
+
+        return $this->_lexemes;
+    }
+
+
+
+    /*********************************************************************
+     * Actions implementation
+     *
+     * Actions affect on recognized lexemes list
+     *********************************************************************/
+
+    /**
+     * Add query syntax lexeme
+     *
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function addQuerySyntaxLexeme()
+    {
+        $lexeme = $this->_queryString[$this->_queryStringPosition];
+
+        // Process two char lexemes
+        if (strpos(self::QUERY_DOUBLECHARLEXEME_CHARS, $lexeme) !== false) {
+            // increase current position in a query string
+            $this->_queryStringPosition++;
+
+            // check,
+            if ($this->_queryStringPosition == count($this->_queryString)  ||
+                $this->_queryString[$this->_queryStringPosition] != $lexeme) {
+                    require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+                    throw new Zend_Search_Lucene_Search_QueryParserException('Two chars lexeme expected. ' . $this->_positionMsg());
+                }
+
+            // duplicate character
+            $lexeme .= $lexeme;
+        }
+
+        $token = new Zend_Search_Lucene_Search_QueryToken(
+                                Zend_Search_Lucene_Search_QueryToken::TC_SYNTAX_ELEMENT,
+                                $lexeme,
+                                $this->_queryStringPosition);
+
+        // Skip this lexeme if it's a field indicator ':' and treat previous as 'field' instead of 'word'
+        if ($token->type == Zend_Search_Lucene_Search_QueryToken::TT_FIELD_INDICATOR) {
+            $token = array_pop($this->_lexemes);
+            if ($token === null  ||  $token->type != Zend_Search_Lucene_Search_QueryToken::TT_WORD) {
+                require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+                throw new Zend_Search_Lucene_Search_QueryParserException('Field mark \':\' must follow field name. ' . $this->_positionMsg());
+            }
+
+            $token->type = Zend_Search_Lucene_Search_QueryToken::TT_FIELD;
+        }
+
+        $this->_lexemes[] = $token;
+    }
+
+    /**
+     * Add lexeme modifier
+     */
+    public function addLexemeModifier()
+    {
+        $this->_lexemes[] = new Zend_Search_Lucene_Search_QueryToken(
+                                    Zend_Search_Lucene_Search_QueryToken::TC_SYNTAX_ELEMENT,
+                                    $this->_queryString[$this->_queryStringPosition],
+                                    $this->_queryStringPosition);
+    }
+
+
+    /**
+     * Add lexeme
+     */
+    public function addLexeme()
+    {
+        $this->_lexemes[] = new Zend_Search_Lucene_Search_QueryToken(
+                                    Zend_Search_Lucene_Search_QueryToken::TC_WORD,
+                                    $this->_currentLexeme,
+                                    $this->_queryStringPosition - 1);
+
+        $this->_currentLexeme = '';
+    }
+
+    /**
+     * Add quoted lexeme
+     */
+    public function addQuotedLexeme()
+    {
+        $this->_lexemes[] = new Zend_Search_Lucene_Search_QueryToken(
+                                    Zend_Search_Lucene_Search_QueryToken::TC_PHRASE,
+                                    $this->_currentLexeme,
+                                    $this->_queryStringPosition);
+
+        $this->_currentLexeme = '';
+    }
+
+    /**
+     * Add number lexeme
+     */
+    public function addNumberLexeme()
+    {
+        $this->_lexemes[] = new Zend_Search_Lucene_Search_QueryToken(
+                                    Zend_Search_Lucene_Search_QueryToken::TC_NUMBER,
+                                    $this->_currentLexeme,
+                                    $this->_queryStringPosition - 1);
+        $this->_currentLexeme = '';
+    }
+
+    /**
+     * Extend lexeme by one char
+     */
+    public function addLexemeChar()
+    {
+        $this->_currentLexeme .= $this->_queryString[$this->_queryStringPosition];
+    }
+
+
+    /**
+     * Position message
+     *
+     * @return string
+     */
+    private function _positionMsg()
+    {
+        return 'Position is ' . $this->_queryStringPosition . '.';
+    }
+
+
+    /*********************************************************************
+     * Syntax errors actions
+     *********************************************************************/
+    public function lexModifierErrException()
+    {
+        require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+        throw new Zend_Search_Lucene_Search_QueryParserException('Lexeme modifier character can be followed only by number, white space or query syntax element. ' . $this->_positionMsg());
+    }
+    public function quoteWithinLexemeErrException()
+    {
+        require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+        throw new Zend_Search_Lucene_Search_QueryParserException('Quote within lexeme must be escaped by \'\\\' char. ' . $this->_positionMsg());
+    }
+    public function wrongNumberErrException()
+    {
+        require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+        throw new Zend_Search_Lucene_Search_QueryParserException('Wrong number syntax.' . $this->_positionMsg());
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryParser.php b/inc/lib/Zend/Search/Lucene/Search/QueryParser.php
new file mode 100644 (file)
index 0000000..41360e5
--- /dev/null
@@ -0,0 +1,636 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: QueryParser.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Index_Term */
+require_once 'Zend/Search/Lucene/Index/Term.php';
+
+/** Zend_Search_Lucene_Search_Query_Term */
+require_once 'Zend/Search/Lucene/Search/Query/Term.php';
+
+/** Zend_Search_Lucene_Search_Query_MultiTerm */
+require_once 'Zend/Search/Lucene/Search/Query/MultiTerm.php';
+
+/** Zend_Search_Lucene_Search_Query_Boolean */
+require_once 'Zend/Search/Lucene/Search/Query/Boolean.php';
+
+/** Zend_Search_Lucene_Search_Query_Preprocessing_Phrase */
+require_once 'Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php';
+
+/** Zend_Search_Lucene_Search_Query_Preprocessing_Term */
+require_once 'Zend/Search/Lucene/Search/Query/Preprocessing/Term.php';
+
+/** Zend_Search_Lucene_Search_Query_Preprocessing_Fuzzy */
+require_once 'Zend/Search/Lucene/Search/Query/Preprocessing/Fuzzy.php';
+
+/** Zend_Search_Lucene_Search_Query_Wildcard */
+require_once 'Zend/Search/Lucene/Search/Query/Wildcard.php';
+
+/** Zend_Search_Lucene_Search_Query_Range */
+require_once 'Zend/Search/Lucene/Search/Query/Range.php';
+
+/** Zend_Search_Lucene_Search_Query_Fuzzy */
+require_once 'Zend/Search/Lucene/Search/Query/Fuzzy.php';
+
+/** Zend_Search_Lucene_Search_Query_Empty */
+require_once 'Zend/Search/Lucene/Search/Query/Empty.php';
+
+/** Zend_Search_Lucene_Search_Query_Insignificant */
+require_once 'Zend/Search/Lucene/Search/Query/Insignificant.php';
+
+/** Zend_Search_Lucene_Search_QueryLexer */
+require_once 'Zend/Search/Lucene/Search/QueryLexer.php';
+
+/** Zend_Search_Lucene_Search_QueryParserContext */
+require_once 'Zend/Search/Lucene/Search/QueryParserContext.php';
+
+/** Zend_Search_Lucene_FSM */
+require_once 'Zend/Search/Lucene/FSM.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryParser extends Zend_Search_Lucene_FSM
+{
+    /**
+     * Parser instance
+     *
+     * @var Zend_Search_Lucene_Search_QueryParser
+     */
+    private static $_instance = null;
+
+
+    /**
+     * Query lexer
+     *
+     * @var Zend_Search_Lucene_Search_QueryLexer
+     */
+    private $_lexer;
+
+    /**
+     * Tokens list
+     * Array of Zend_Search_Lucene_Search_QueryToken objects
+     *
+     * @var array
+     */
+    private $_tokens;
+
+    /**
+     * Current token
+     *
+     * @var integer|string
+     */
+    private $_currentToken;
+
+    /**
+     * Last token
+     *
+     * It can be processed within FSM states, but this addirional state simplifies FSM
+     *
+     * @var Zend_Search_Lucene_Search_QueryToken
+     */
+    private $_lastToken = null;
+
+    /**
+     * Range query first term
+     *
+     * @var string
+     */
+    private $_rqFirstTerm = null;
+
+    /**
+     * Current query parser context
+     *
+     * @var Zend_Search_Lucene_Search_QueryParserContext
+     */
+    private $_context;
+
+    /**
+     * Context stack
+     *
+     * @var array
+     */
+    private $_contextStack;
+
+    /**
+     * Query string encoding
+     *
+     * @var string
+     */
+    private $_encoding;
+
+    /**
+     * Query string default encoding
+     *
+     * @var string
+     */
+    private $_defaultEncoding = '';
+
+    /**
+     * Defines query parsing mode.
+     *
+     * If this option is turned on, then query parser suppress query parser exceptions
+     * and constructs multi-term query using all words from a query.
+     *
+     * That helps to avoid exceptions caused by queries, which don't conform to query language,
+     * but limits possibilities to check, that query entered by user has some inconsistencies.
+     *
+     *
+     * Default is true.
+     *
+     * Use {@link Zend_Search_Lucene::suppressQueryParsingExceptions()},
+     * {@link Zend_Search_Lucene::dontSuppressQueryParsingExceptions()} and
+     * {@link Zend_Search_Lucene::checkQueryParsingExceptionsSuppressMode()} to operate
+     * with this setting.
+     *
+     * @var boolean
+     */
+    private $_suppressQueryParsingExceptions = true;
+
+    /**
+     * Boolean operators constants
+     */
+    const B_OR  = 0;
+    const B_AND = 1;
+
+    /**
+     * Default boolean queries operator
+     *
+     * @var integer
+     */
+    private $_defaultOperator = self::B_OR;
+
+
+    /** Query parser State Machine states */
+    const ST_COMMON_QUERY_ELEMENT       = 0;   // Terms, phrases, operators
+    const ST_CLOSEDINT_RQ_START         = 1;   // Range query start (closed interval) - '['
+    const ST_CLOSEDINT_RQ_FIRST_TERM    = 2;   // First term in '[term1 to term2]' construction
+    const ST_CLOSEDINT_RQ_TO_TERM       = 3;   // 'TO' lexeme in '[term1 to term2]' construction
+    const ST_CLOSEDINT_RQ_LAST_TERM     = 4;   // Second term in '[term1 to term2]' construction
+    const ST_CLOSEDINT_RQ_END           = 5;   // Range query end (closed interval) - ']'
+    const ST_OPENEDINT_RQ_START         = 6;   // Range query start (opened interval) - '{'
+    const ST_OPENEDINT_RQ_FIRST_TERM    = 7;   // First term in '{term1 to term2}' construction
+    const ST_OPENEDINT_RQ_TO_TERM       = 8;   // 'TO' lexeme in '{term1 to term2}' construction
+    const ST_OPENEDINT_RQ_LAST_TERM     = 9;   // Second term in '{term1 to term2}' construction
+    const ST_OPENEDINT_RQ_END           = 10;  // Range query end (opened interval) - '}'
+
+    /**
+     * Parser constructor
+     */
+    public function __construct()
+    {
+        parent::__construct(array(self::ST_COMMON_QUERY_ELEMENT,
+                                  self::ST_CLOSEDINT_RQ_START,
+                                  self::ST_CLOSEDINT_RQ_FIRST_TERM,
+                                  self::ST_CLOSEDINT_RQ_TO_TERM,
+                                  self::ST_CLOSEDINT_RQ_LAST_TERM,
+                                  self::ST_CLOSEDINT_RQ_END,
+                                  self::ST_OPENEDINT_RQ_START,
+                                  self::ST_OPENEDINT_RQ_FIRST_TERM,
+                                  self::ST_OPENEDINT_RQ_TO_TERM,
+                                  self::ST_OPENEDINT_RQ_LAST_TERM,
+                                  self::ST_OPENEDINT_RQ_END
+                                 ),
+                            Zend_Search_Lucene_Search_QueryToken::getTypes());
+
+        $this->addRules(
+             array(array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_WORD,             self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_PHRASE,           self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_FIELD,            self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_REQUIRED,         self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_PROHIBITED,       self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_FUZZY_PROX_MARK,  self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_BOOSTING_MARK,    self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_RANGE_INCL_START, self::ST_CLOSEDINT_RQ_START),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_RANGE_EXCL_START, self::ST_OPENEDINT_RQ_START),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_SUBQUERY_START,   self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_SUBQUERY_END,     self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_AND_LEXEME,       self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_OR_LEXEME,        self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_NOT_LEXEME,       self::ST_COMMON_QUERY_ELEMENT),
+                   array(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_NUMBER,           self::ST_COMMON_QUERY_ELEMENT)
+                  ));
+        $this->addRules(
+             array(array(self::ST_CLOSEDINT_RQ_START,      Zend_Search_Lucene_Search_QueryToken::TT_WORD,           self::ST_CLOSEDINT_RQ_FIRST_TERM),
+                   array(self::ST_CLOSEDINT_RQ_FIRST_TERM, Zend_Search_Lucene_Search_QueryToken::TT_TO_LEXEME,      self::ST_CLOSEDINT_RQ_TO_TERM),
+                   array(self::ST_CLOSEDINT_RQ_TO_TERM,    Zend_Search_Lucene_Search_QueryToken::TT_WORD,           self::ST_CLOSEDINT_RQ_LAST_TERM),
+                   array(self::ST_CLOSEDINT_RQ_LAST_TERM,  Zend_Search_Lucene_Search_QueryToken::TT_RANGE_INCL_END, self::ST_COMMON_QUERY_ELEMENT)
+                  ));
+        $this->addRules(
+             array(array(self::ST_OPENEDINT_RQ_START,      Zend_Search_Lucene_Search_QueryToken::TT_WORD,           self::ST_OPENEDINT_RQ_FIRST_TERM),
+                   array(self::ST_OPENEDINT_RQ_FIRST_TERM, Zend_Search_Lucene_Search_QueryToken::TT_TO_LEXEME,      self::ST_OPENEDINT_RQ_TO_TERM),
+                   array(self::ST_OPENEDINT_RQ_TO_TERM,    Zend_Search_Lucene_Search_QueryToken::TT_WORD,           self::ST_OPENEDINT_RQ_LAST_TERM),
+                   array(self::ST_OPENEDINT_RQ_LAST_TERM,  Zend_Search_Lucene_Search_QueryToken::TT_RANGE_EXCL_END, self::ST_COMMON_QUERY_ELEMENT)
+                  ));
+
+
+
+        $addTermEntryAction             = new Zend_Search_Lucene_FSMAction($this, 'addTermEntry');
+        $addPhraseEntryAction           = new Zend_Search_Lucene_FSMAction($this, 'addPhraseEntry');
+        $setFieldAction                 = new Zend_Search_Lucene_FSMAction($this, 'setField');
+        $setSignAction                  = new Zend_Search_Lucene_FSMAction($this, 'setSign');
+        $setFuzzyProxAction             = new Zend_Search_Lucene_FSMAction($this, 'processFuzzyProximityModifier');
+        $processModifierParameterAction = new Zend_Search_Lucene_FSMAction($this, 'processModifierParameter');
+        $subqueryStartAction            = new Zend_Search_Lucene_FSMAction($this, 'subqueryStart');
+        $subqueryEndAction              = new Zend_Search_Lucene_FSMAction($this, 'subqueryEnd');
+        $logicalOperatorAction          = new Zend_Search_Lucene_FSMAction($this, 'logicalOperator');
+        $openedRQFirstTermAction        = new Zend_Search_Lucene_FSMAction($this, 'openedRQFirstTerm');
+        $openedRQLastTermAction         = new Zend_Search_Lucene_FSMAction($this, 'openedRQLastTerm');
+        $closedRQFirstTermAction        = new Zend_Search_Lucene_FSMAction($this, 'closedRQFirstTerm');
+        $closedRQLastTermAction         = new Zend_Search_Lucene_FSMAction($this, 'closedRQLastTerm');
+
+
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_WORD,            $addTermEntryAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_PHRASE,          $addPhraseEntryAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_FIELD,           $setFieldAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_REQUIRED,        $setSignAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_PROHIBITED,      $setSignAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_FUZZY_PROX_MARK, $setFuzzyProxAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_NUMBER,          $processModifierParameterAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_SUBQUERY_START,  $subqueryStartAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_SUBQUERY_END,    $subqueryEndAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_AND_LEXEME,      $logicalOperatorAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_OR_LEXEME,       $logicalOperatorAction);
+        $this->addInputAction(self::ST_COMMON_QUERY_ELEMENT, Zend_Search_Lucene_Search_QueryToken::TT_NOT_LEXEME,      $logicalOperatorAction);
+
+        $this->addEntryAction(self::ST_OPENEDINT_RQ_FIRST_TERM, $openedRQFirstTermAction);
+        $this->addEntryAction(self::ST_OPENEDINT_RQ_LAST_TERM,  $openedRQLastTermAction);
+        $this->addEntryAction(self::ST_CLOSEDINT_RQ_FIRST_TERM, $closedRQFirstTermAction);
+        $this->addEntryAction(self::ST_CLOSEDINT_RQ_LAST_TERM,  $closedRQLastTermAction);
+
+
+
+        $this->_lexer = new Zend_Search_Lucene_Search_QueryLexer();
+    }
+
+    /**
+     * Get query parser instance
+     *
+     * @return Zend_Search_Lucene_Search_QueryParser
+     */
+    private static function _getInstance()
+    {
+        if (self::$_instance === null) {
+            self::$_instance = new self();
+        }
+        return self::$_instance;
+    }
+
+    /**
+     * Set query string default encoding
+     *
+     * @param string $encoding
+     */
+    public static function setDefaultEncoding($encoding)
+    {
+        self::_getInstance()->_defaultEncoding = $encoding;
+    }
+
+    /**
+     * Get query string default encoding
+     *
+     * @return string
+     */
+    public static function getDefaultEncoding()
+    {
+       return self::_getInstance()->_defaultEncoding;
+    }
+
+    /**
+     * Set default boolean operator
+     *
+     * @param integer $operator
+     */
+    public static function setDefaultOperator($operator)
+    {
+        self::_getInstance()->_defaultOperator = $operator;
+    }
+
+    /**
+     * Get default boolean operator
+     *
+     * @return integer
+     */
+    public static function getDefaultOperator()
+    {
+        return self::_getInstance()->_defaultOperator;
+    }
+
+    /**
+     * Turn on 'suppress query parser exceptions' mode.
+     */
+    public static function suppressQueryParsingExceptions()
+    {
+        self::_getInstance()->_suppressQueryParsingExceptions = true;
+    }
+    /**
+     * Turn off 'suppress query parser exceptions' mode.
+     */
+    public static function dontSuppressQueryParsingExceptions()
+    {
+        self::_getInstance()->_suppressQueryParsingExceptions = false;
+    }
+    /**
+     * Check 'suppress query parser exceptions' mode.
+     * @return boolean
+     */
+    public static function queryParsingExceptionsSuppressed()
+    {
+        return self::_getInstance()->_suppressQueryParsingExceptions;
+    }
+
+
+
+    /**
+     * Parses a query string
+     *
+     * @param string $strQuery
+     * @param string $encoding
+     * @return Zend_Search_Lucene_Search_Query
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public static function parse($strQuery, $encoding = null)
+    {
+        self::_getInstance();
+
+        // Reset FSM if previous parse operation didn't return it into a correct state
+        self::$_instance->reset();
+
+        require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+        try {
+            self::$_instance->_encoding     = ($encoding !== null) ? $encoding : self::$_instance->_defaultEncoding;
+            self::$_instance->_lastToken    = null;
+            self::$_instance->_context      = new Zend_Search_Lucene_Search_QueryParserContext(self::$_instance->_encoding);
+            self::$_instance->_contextStack = array();
+            self::$_instance->_tokens       = self::$_instance->_lexer->tokenize($strQuery, self::$_instance->_encoding);
+
+            // Empty query
+            if (count(self::$_instance->_tokens) == 0) {
+                return new Zend_Search_Lucene_Search_Query_Insignificant();
+            }
+
+
+            foreach (self::$_instance->_tokens as $token) {
+                try {
+                    self::$_instance->_currentToken = $token;
+                    self::$_instance->process($token->type);
+
+                    self::$_instance->_lastToken = $token;
+                } catch (Exception $e) {
+                    if (strpos($e->getMessage(), 'There is no any rule for') !== false) {
+                        throw new Zend_Search_Lucene_Search_QueryParserException( 'Syntax error at char position ' . $token->position . '.' );
+                    }
+
+                    throw $e;
+                }
+            }
+
+            if (count(self::$_instance->_contextStack) != 0) {
+                throw new Zend_Search_Lucene_Search_QueryParserException('Syntax Error: mismatched parentheses, every opening must have closing.' );
+            }
+
+            return self::$_instance->_context->getQuery();
+        } catch (Zend_Search_Lucene_Search_QueryParserException $e) {
+            if (self::$_instance->_suppressQueryParsingExceptions) {
+                $queryTokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($strQuery, self::$_instance->_encoding);
+
+                $query = new Zend_Search_Lucene_Search_Query_MultiTerm();
+                $termsSign = (self::$_instance->_defaultOperator == self::B_AND) ? true /* required term */ :
+                                                                                   null /* optional term */;
+
+                foreach ($queryTokens as $token) {
+                    $query->addTerm(new Zend_Search_Lucene_Index_Term($token->getTermText()), $termsSign);
+                }
+
+
+                return $query;
+            } else {
+                throw $e;
+            }
+        }
+    }
+
+    /*********************************************************************
+     * Actions implementation
+     *
+     * Actions affect on recognized lexemes list
+     *********************************************************************/
+
+    /**
+     * Add term to a query
+     */
+    public function addTermEntry()
+    {
+        $entry = new Zend_Search_Lucene_Search_QueryEntry_Term($this->_currentToken->text, $this->_context->getField());
+        $this->_context->addEntry($entry);
+    }
+
+    /**
+     * Add phrase to a query
+     */
+    public function addPhraseEntry()
+    {
+        $entry = new Zend_Search_Lucene_Search_QueryEntry_Phrase($this->_currentToken->text, $this->_context->getField());
+        $this->_context->addEntry($entry);
+    }
+
+    /**
+     * Set entry field
+     */
+    public function setField()
+    {
+        $this->_context->setNextEntryField($this->_currentToken->text);
+    }
+
+    /**
+     * Set entry sign
+     */
+    public function setSign()
+    {
+        $this->_context->setNextEntrySign($this->_currentToken->type);
+    }
+
+
+    /**
+     * Process fuzzy search/proximity modifier - '~'
+     */
+    public function processFuzzyProximityModifier()
+    {
+        $this->_context->processFuzzyProximityModifier();
+    }
+
+    /**
+     * Process modifier parameter
+     *
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function processModifierParameter()
+    {
+        if ($this->_lastToken === null) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Lexeme modifier parameter must follow lexeme modifier. Char position 0.' );
+        }
+
+        switch ($this->_lastToken->type) {
+            case Zend_Search_Lucene_Search_QueryToken::TT_FUZZY_PROX_MARK:
+                $this->_context->processFuzzyProximityModifier($this->_currentToken->text);
+                break;
+
+            case Zend_Search_Lucene_Search_QueryToken::TT_BOOSTING_MARK:
+                $this->_context->boost($this->_currentToken->text);
+                break;
+
+            default:
+                // It's not a user input exception
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Lexeme modifier parameter must follow lexeme modifier. Char position 0.' );
+        }
+    }
+
+
+    /**
+     * Start subquery
+     */
+    public function subqueryStart()
+    {
+        $this->_contextStack[] = $this->_context;
+        $this->_context        = new Zend_Search_Lucene_Search_QueryParserContext($this->_encoding, $this->_context->getField());
+    }
+
+    /**
+     * End subquery
+     */
+    public function subqueryEnd()
+    {
+        if (count($this->_contextStack) == 0) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Syntax Error: mismatched parentheses, every opening must have closing. Char position ' . $this->_currentToken->position . '.' );
+        }
+
+        $query          = $this->_context->getQuery();
+        $this->_context = array_pop($this->_contextStack);
+
+        $this->_context->addEntry(new Zend_Search_Lucene_Search_QueryEntry_Subquery($query));
+    }
+
+    /**
+     * Process logical operator
+     */
+    public function logicalOperator()
+    {
+        $this->_context->addLogicalOperator($this->_currentToken->type);
+    }
+
+    /**
+     * Process first range query term (opened interval)
+     */
+    public function openedRQFirstTerm()
+    {
+        $this->_rqFirstTerm = $this->_currentToken->text;
+    }
+
+    /**
+     * Process last range query term (opened interval)
+     *
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function openedRQLastTerm()
+    {
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_rqFirstTerm, $this->_encoding);
+        if (count($tokens) > 1) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Range query boundary terms must be non-multiple word terms');
+        } else if (count($tokens) == 1) {
+            $from = new Zend_Search_Lucene_Index_Term(reset($tokens)->getTermText(), $this->_context->getField());
+        } else {
+            $from = null;
+        }
+
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_currentToken->text, $this->_encoding);
+        if (count($tokens) > 1) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Range query boundary terms must be non-multiple word terms');
+        } else if (count($tokens) == 1) {
+            $to = new Zend_Search_Lucene_Index_Term(reset($tokens)->getTermText(), $this->_context->getField());
+        } else {
+            $to = null;
+        }
+
+        if ($from === null  &&  $to === null) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('At least one range query boundary term must be non-empty term');
+        }
+
+        $rangeQuery = new Zend_Search_Lucene_Search_Query_Range($from, $to, false);
+        $entry      = new Zend_Search_Lucene_Search_QueryEntry_Subquery($rangeQuery);
+        $this->_context->addEntry($entry);
+    }
+
+    /**
+     * Process first range query term (closed interval)
+     */
+    public function closedRQFirstTerm()
+    {
+        $this->_rqFirstTerm = $this->_currentToken->text;
+    }
+
+    /**
+     * Process last range query term (closed interval)
+     *
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function closedRQLastTerm()
+    {
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_rqFirstTerm, $this->_encoding);
+        if (count($tokens) > 1) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Range query boundary terms must be non-multiple word terms');
+        } else if (count($tokens) == 1) {
+            $from = new Zend_Search_Lucene_Index_Term(reset($tokens)->getTermText(), $this->_context->getField());
+        } else {
+            $from = null;
+        }
+
+        $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_currentToken->text, $this->_encoding);
+        if (count($tokens) > 1) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Range query boundary terms must be non-multiple word terms');
+        } else if (count($tokens) == 1) {
+            $to = new Zend_Search_Lucene_Index_Term(reset($tokens)->getTermText(), $this->_context->getField());
+        } else {
+            $to = null;
+        }
+
+        if ($from === null  &&  $to === null) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('At least one range query boundary term must be non-empty term');
+        }
+
+        $rangeQuery = new Zend_Search_Lucene_Search_Query_Range($from, $to, true);
+        $entry      = new Zend_Search_Lucene_Search_QueryEntry_Subquery($rangeQuery);
+        $this->_context->addEntry($entry);
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryParserContext.php b/inc/lib/Zend/Search/Lucene/Search/QueryParserContext.php
new file mode 100644 (file)
index 0000000..1f3bd92
--- /dev/null
@@ -0,0 +1,418 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: QueryParserContext.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_FSM */
+require_once 'Zend/Search/Lucene/FSM.php';
+
+/** Zend_Search_Lucene_Index_Term */
+require_once 'Zend/Search/Lucene/Index/Term.php';
+
+/** Zend_Search_Lucene_Search_QueryToken */
+require_once 'Zend/Search/Lucene/Search/QueryToken.php';
+
+/** Zend_Search_Lucene_Search_Query_Term */
+require_once 'Zend/Search/Lucene/Search/Query/Term.php';
+
+/** Zend_Search_Lucene_Search_Query_MultiTerm */
+require_once 'Zend/Search/Lucene/Search/Query/MultiTerm.php';
+
+/** Zend_Search_Lucene_Search_Query_Boolean */
+require_once 'Zend/Search/Lucene/Search/Query/Boolean.php';
+
+/** Zend_Search_Lucene_Search_Query_Phrase */
+require_once 'Zend/Search/Lucene/Search/Query/Phrase.php';
+
+/** Zend_Search_Lucene_Search_BooleanExpressionRecognizer */
+require_once 'Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php';
+
+/** Zend_Search_Lucene_Search_QueryEntry */
+require_once 'Zend/Search/Lucene/Search/QueryEntry.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryParserContext
+{
+    /**
+     * Default field for the context.
+     *
+     * null means, that term should be searched through all fields
+     * Zend_Search_Lucene_Search_Query::rewriteQuery($index) transletes such queries to several
+     *
+     * @var string|null
+     */
+    private $_defaultField;
+
+    /**
+     * Field specified for next entry
+     *
+     * @var string
+     */
+    private $_nextEntryField = null;
+
+    /**
+     * True means, that term is required.
+     * False means, that term is prohibited.
+     * null means, that term is neither prohibited, nor required
+     *
+     * @var boolean
+     */
+    private $_nextEntrySign = null;
+
+
+    /**
+     * Entries grouping mode
+     */
+    const GM_SIGNS   = 0;  // Signs mode: '+term1 term2 -term3 +(subquery1) -(subquery2)'
+    const GM_BOOLEAN = 1;  // Boolean operators mode: 'term1 and term2  or  (subquery1) and not (subquery2)'
+
+    /**
+     * Grouping mode
+     *
+     * @var integer
+     */
+    private $_mode = null;
+
+    /**
+     * Entries signs.
+     * Used in GM_SIGNS grouping mode
+     *
+     * @var arrays
+     */
+    private $_signs = array();
+
+    /**
+     * Query entries
+     * Each entry is a Zend_Search_Lucene_Search_QueryEntry object or
+     * boolean operator (Zend_Search_Lucene_Search_QueryToken class constant)
+     *
+     * @var array
+     */
+    private $_entries = array();
+
+    /**
+     * Query string encoding
+     *
+     * @var string
+     */
+    private $_encoding;
+
+
+    /**
+     * Context object constructor
+     *
+     * @param string $encoding
+     * @param string|null $defaultField
+     */
+    public function __construct($encoding, $defaultField = null)
+    {
+        $this->_encoding     = $encoding;
+        $this->_defaultField = $defaultField;
+    }
+
+
+    /**
+     * Get context default field
+     *
+     * @return string|null
+     */
+    public function getField()
+    {
+        return ($this->_nextEntryField !== null)  ?  $this->_nextEntryField : $this->_defaultField;
+    }
+
+    /**
+     * Set field for next entry
+     *
+     * @param string $field
+     */
+    public function setNextEntryField($field)
+    {
+        $this->_nextEntryField = $field;
+    }
+
+
+    /**
+     * Set sign for next entry
+     *
+     * @param integer $sign
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function setNextEntrySign($sign)
+    {
+        if ($this->_mode === self::GM_BOOLEAN) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('It\'s not allowed to mix boolean and signs styles in the same subquery.');
+        }
+
+        $this->_mode = self::GM_SIGNS;
+
+        if ($sign == Zend_Search_Lucene_Search_QueryToken::TT_REQUIRED) {
+            $this->_nextEntrySign = true;
+        } else if ($sign == Zend_Search_Lucene_Search_QueryToken::TT_PROHIBITED) {
+            $this->_nextEntrySign = false;
+        } else {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Unrecognized sign type.');
+        }
+    }
+
+
+    /**
+     * Add entry to a query
+     *
+     * @param Zend_Search_Lucene_Search_QueryEntry $entry
+     */
+    public function addEntry(Zend_Search_Lucene_Search_QueryEntry $entry)
+    {
+        if ($this->_mode !== self::GM_BOOLEAN) {
+            $this->_signs[] = $this->_nextEntrySign;
+        }
+
+        $this->_entries[] = $entry;
+
+        $this->_nextEntryField = null;
+        $this->_nextEntrySign  = null;
+    }
+
+
+    /**
+     * Process fuzzy search or proximity search modifier
+     *
+     * @throws Zend_Search_Lucene_Search_QueryParserException
+     */
+    public function processFuzzyProximityModifier($parameter = null)
+    {
+        // Check, that modifier has came just after word or phrase
+        if ($this->_nextEntryField !== null  ||  $this->_nextEntrySign !== null) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('\'~\' modifier must follow word or phrase.');
+        }
+
+        $lastEntry = array_pop($this->_entries);
+
+        if (!$lastEntry instanceof Zend_Search_Lucene_Search_QueryEntry) {
+            // there are no entries or last entry is boolean operator
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('\'~\' modifier must follow word or phrase.');
+        }
+
+        $lastEntry->processFuzzyProximityModifier($parameter);
+
+        $this->_entries[] = $lastEntry;
+    }
+
+    /**
+     * Set boost factor to the entry
+     *
+     * @param float $boostFactor
+     */
+    public function boost($boostFactor)
+    {
+        // Check, that modifier has came just after word or phrase
+        if ($this->_nextEntryField !== null  ||  $this->_nextEntrySign !== null) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('\'^\' modifier must follow word, phrase or subquery.');
+        }
+
+        $lastEntry = array_pop($this->_entries);
+
+        if (!$lastEntry instanceof Zend_Search_Lucene_Search_QueryEntry) {
+            // there are no entries or last entry is boolean operator
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('\'^\' modifier must follow word, phrase or subquery.');
+        }
+
+        $lastEntry->boost($boostFactor);
+
+        $this->_entries[] = $lastEntry;
+    }
+
+    /**
+     * Process logical operator
+     *
+     * @param integer $operator
+     */
+    public function addLogicalOperator($operator)
+    {
+        if ($this->_mode === self::GM_SIGNS) {
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('It\'s not allowed to mix boolean and signs styles in the same subquery.');
+        }
+
+        $this->_mode = self::GM_BOOLEAN;
+
+        $this->_entries[] = $operator;
+    }
+
+
+    /**
+     * Generate 'signs style' query from the context
+     * '+term1 term2 -term3 +(<subquery1>) ...'
+     *
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function _signStyleExpressionQuery()
+    {
+        $query = new Zend_Search_Lucene_Search_Query_Boolean();
+
+        if (Zend_Search_Lucene_Search_QueryParser::getDefaultOperator() == Zend_Search_Lucene_Search_QueryParser::B_AND) {
+            $defaultSign = true; // required
+        } else {
+            // Zend_Search_Lucene_Search_QueryParser::B_OR
+            $defaultSign = null; // optional
+        }
+
+        foreach ($this->_entries as $entryId => $entry) {
+            $sign = ($this->_signs[$entryId] !== null) ?  $this->_signs[$entryId] : $defaultSign;
+            $query->addSubquery($entry->getQuery($this->_encoding), $sign);
+        }
+
+        return $query;
+    }
+
+
+    /**
+     * Generate 'boolean style' query from the context
+     * 'term1 and term2   or   term3 and (<subquery1>) and not (<subquery2>)'
+     *
+     * @return Zend_Search_Lucene_Search_Query
+     * @throws Zend_Search_Lucene
+     */
+    private function _booleanExpressionQuery()
+    {
+        /**
+         * We treat each level of an expression as a boolean expression in
+         * a Disjunctive Normal Form
+         *
+         * AND operator has higher precedence than OR
+         *
+         * Thus logical query is a disjunction of one or more conjunctions of
+         * one or more query entries
+         */
+
+        $expressionRecognizer = new Zend_Search_Lucene_Search_BooleanExpressionRecognizer();
+
+        require_once 'Zend/Search/Lucene/Exception.php';
+        try {
+            foreach ($this->_entries as $entry) {
+                if ($entry instanceof Zend_Search_Lucene_Search_QueryEntry) {
+                    $expressionRecognizer->processLiteral($entry);
+                } else {
+                    switch ($entry) {
+                        case Zend_Search_Lucene_Search_QueryToken::TT_AND_LEXEME:
+                            $expressionRecognizer->processOperator(Zend_Search_Lucene_Search_BooleanExpressionRecognizer::IN_AND_OPERATOR);
+                            break;
+
+                        case Zend_Search_Lucene_Search_QueryToken::TT_OR_LEXEME:
+                            $expressionRecognizer->processOperator(Zend_Search_Lucene_Search_BooleanExpressionRecognizer::IN_OR_OPERATOR);
+                            break;
+
+                        case Zend_Search_Lucene_Search_QueryToken::TT_NOT_LEXEME:
+                            $expressionRecognizer->processOperator(Zend_Search_Lucene_Search_BooleanExpressionRecognizer::IN_NOT_OPERATOR);
+                            break;
+
+                        default:
+                            throw new Zend_Search_Lucene('Boolean expression error. Unknown operator type.');
+                    }
+                }
+            }
+
+            $conjuctions = $expressionRecognizer->finishExpression();
+        } catch (Zend_Search_Exception $e) {
+            // throw new Zend_Search_Lucene_Search_QueryParserException('Boolean expression error. Error message: \'' .
+            //                                                          $e->getMessage() . '\'.' );
+            // It's query syntax error message and it should be user friendly. So FSM message is omitted
+            require_once 'Zend/Search/Lucene/Search/QueryParserException.php';
+            throw new Zend_Search_Lucene_Search_QueryParserException('Boolean expression error.');
+        }
+
+        // Remove 'only negative' conjunctions
+        foreach ($conjuctions as $conjuctionId => $conjuction) {
+            $nonNegativeEntryFound = false;
+
+            foreach ($conjuction as $conjuctionEntry) {
+                if ($conjuctionEntry[1]) {
+                    $nonNegativeEntryFound = true;
+                    break;
+                }
+            }
+
+            if (!$nonNegativeEntryFound) {
+                unset($conjuctions[$conjuctionId]);
+            }
+        }
+
+
+        $subqueries = array();
+        foreach ($conjuctions as  $conjuction) {
+            // Check, if it's a one term conjuction
+            if (count($conjuction) == 1) {
+                $subqueries[] = $conjuction[0][0]->getQuery($this->_encoding);
+            } else {
+                $subquery = new Zend_Search_Lucene_Search_Query_Boolean();
+
+                foreach ($conjuction as $conjuctionEntry) {
+                    $subquery->addSubquery($conjuctionEntry[0]->getQuery($this->_encoding), $conjuctionEntry[1]);
+                }
+
+                $subqueries[] = $subquery;
+            }
+        }
+
+        if (count($subqueries) == 0) {
+            return new Zend_Search_Lucene_Search_Query_Insignificant();
+        }
+
+        if (count($subqueries) == 1) {
+            return $subqueries[0];
+        }
+
+
+        $query = new Zend_Search_Lucene_Search_Query_Boolean();
+
+        foreach ($subqueries as $subquery) {
+            // Non-requirered entry/subquery
+            $query->addSubquery($subquery);
+        }
+
+        return $query;
+    }
+
+    /**
+     * Generate query from current context
+     *
+     * @return Zend_Search_Lucene_Search_Query
+     */
+    public function getQuery()
+    {
+        if ($this->_mode === self::GM_BOOLEAN) {
+            return $this->_booleanExpressionQuery();
+        } else {
+            return $this->_signStyleExpressionQuery();
+        }
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryParserException.php b/inc/lib/Zend/Search/Lucene/Search/QueryParserException.php
new file mode 100644 (file)
index 0000000..e17bd93
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: QueryParserException.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/**
+ * Zend_Search_Lucene base exception
+ */
+require_once 'Zend/Search/Lucene/Exception.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ *
+ * Special exception type, which may be used to intercept wrong user input
+ */
+class Zend_Search_Lucene_Search_QueryParserException extends Zend_Search_Lucene_Exception
+{}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/QueryToken.php b/inc/lib/Zend/Search/Lucene/Search/QueryToken.php
new file mode 100644 (file)
index 0000000..a231c43
--- /dev/null
@@ -0,0 +1,225 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: QueryToken.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_QueryToken
+{
+    /**
+     * Token types.
+     */
+    const TT_WORD                 = 0;  // Word
+    const TT_PHRASE               = 1;  // Phrase (one or several quoted words)
+    const TT_FIELD                = 2;  // Field name in 'field:word', field:<phrase> or field:(<subquery>) pairs
+    const TT_FIELD_INDICATOR      = 3;  // ':'
+    const TT_REQUIRED             = 4;  // '+'
+    const TT_PROHIBITED           = 5;  // '-'
+    const TT_FUZZY_PROX_MARK      = 6;  // '~'
+    const TT_BOOSTING_MARK        = 7;  // '^'
+    const TT_RANGE_INCL_START     = 8;  // '['
+    const TT_RANGE_INCL_END       = 9;  // ']'
+    const TT_RANGE_EXCL_START     = 10; // '{'
+    const TT_RANGE_EXCL_END       = 11; // '}'
+    const TT_SUBQUERY_START       = 12; // '('
+    const TT_SUBQUERY_END         = 13; // ')'
+    const TT_AND_LEXEME           = 14; // 'AND' or 'and'
+    const TT_OR_LEXEME            = 15; // 'OR'  or 'or'
+    const TT_NOT_LEXEME           = 16; // 'NOT' or 'not'
+    const TT_TO_LEXEME            = 17; // 'TO'  or 'to'
+    const TT_NUMBER               = 18; // Number, like: 10, 0.8, .64, ....
+
+
+    /**
+     * Returns all possible lexeme types.
+     * It's used for syntax analyzer state machine initialization
+     *
+     * @return array
+     */
+    public static function getTypes()
+    {
+        return array(   self::TT_WORD,
+                        self::TT_PHRASE,
+                        self::TT_FIELD,
+                        self::TT_FIELD_INDICATOR,
+                        self::TT_REQUIRED,
+                        self::TT_PROHIBITED,
+                        self::TT_FUZZY_PROX_MARK,
+                        self::TT_BOOSTING_MARK,
+                        self::TT_RANGE_INCL_START,
+                        self::TT_RANGE_INCL_END,
+                        self::TT_RANGE_EXCL_START,
+                        self::TT_RANGE_EXCL_END,
+                        self::TT_SUBQUERY_START,
+                        self::TT_SUBQUERY_END,
+                        self::TT_AND_LEXEME,
+                        self::TT_OR_LEXEME,
+                        self::TT_NOT_LEXEME,
+                        self::TT_TO_LEXEME,
+                        self::TT_NUMBER
+                     );
+    }
+
+
+    /**
+     * TokenCategories
+     */
+    const TC_WORD           = 0;   // Word
+    const TC_PHRASE         = 1;   // Phrase (one or several quoted words)
+    const TC_NUMBER         = 2;   // Nubers, which are used with syntax elements. Ex. roam~0.8
+    const TC_SYNTAX_ELEMENT = 3;   // +  -  ( )  [ ]  { }  !  ||  && ~ ^
+
+
+    /**
+     * Token type.
+     *
+     * @var integer
+     */
+    public $type;
+
+    /**
+     * Token text.
+     *
+     * @var integer
+     */
+    public $text;
+
+    /**
+     * Token position within query.
+     *
+     * @var integer
+     */
+    public $position;
+
+
+    /**
+     * IndexReader constructor needs token type and token text as a parameters.
+     *
+     * @param integer $tokenCategory
+     * @param string  $tokText
+     * @param integer $position
+     */
+    public function __construct($tokenCategory, $tokenText, $position)
+    {
+        $this->text     = $tokenText;
+        $this->position = $position + 1; // Start from 1
+
+        switch ($tokenCategory) {
+            case self::TC_WORD:
+                if (  strtolower($tokenText) == 'and') {
+                    $this->type = self::TT_AND_LEXEME;
+                } else if (strtolower($tokenText) == 'or') {
+                    $this->type = self::TT_OR_LEXEME;
+                } else if (strtolower($tokenText) == 'not') {
+                    $this->type = self::TT_NOT_LEXEME;
+                } else if (strtolower($tokenText) == 'to') {
+                    $this->type = self::TT_TO_LEXEME;
+                } else {
+                    $this->type = self::TT_WORD;
+                }
+                break;
+
+            case self::TC_PHRASE:
+                $this->type = self::TT_PHRASE;
+                break;
+
+            case self::TC_NUMBER:
+                $this->type = self::TT_NUMBER;
+                break;
+
+            case self::TC_SYNTAX_ELEMENT:
+                switch ($tokenText) {
+                    case ':':
+                        $this->type = self::TT_FIELD_INDICATOR;
+                        break;
+
+                    case '+':
+                        $this->type = self::TT_REQUIRED;
+                        break;
+
+                    case '-':
+                        $this->type = self::TT_PROHIBITED;
+                        break;
+
+                    case '~':
+                        $this->type = self::TT_FUZZY_PROX_MARK;
+                        break;
+
+                    case '^':
+                        $this->type = self::TT_BOOSTING_MARK;
+                        break;
+
+                    case '[':
+                        $this->type = self::TT_RANGE_INCL_START;
+                        break;
+
+                    case ']':
+                        $this->type = self::TT_RANGE_INCL_END;
+                        break;
+
+                    case '{':
+                        $this->type = self::TT_RANGE_EXCL_START;
+                        break;
+
+                    case '}':
+                        $this->type = self::TT_RANGE_EXCL_END;
+                        break;
+
+                    case '(':
+                        $this->type = self::TT_SUBQUERY_START;
+                        break;
+
+                    case ')':
+                        $this->type = self::TT_SUBQUERY_END;
+                        break;
+
+                    case '!':
+                        $this->type = self::TT_NOT_LEXEME;
+                        break;
+
+                    case '&&':
+                        $this->type = self::TT_AND_LEXEME;
+                        break;
+
+                    case '||':
+                        $this->type = self::TT_OR_LEXEME;
+                        break;
+
+                    default:
+                        require_once 'Zend/Search/Lucene/Exception.php';
+                        throw new Zend_Search_Lucene_Exception('Unrecognized query syntax lexeme: \'' . $tokenText . '\'');
+                }
+                break;
+
+            case self::TC_NUMBER:
+                $this->type = self::TT_NUMBER;
+
+            default:
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Unrecognized lexeme type: \'' . $tokenCategory . '\'');
+        }
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/Similarity.php b/inc/lib/Zend/Search/Lucene/Search/Similarity.php
new file mode 100644 (file)
index 0000000..cd16414
--- /dev/null
@@ -0,0 +1,554 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Similarity.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Similarity_Default */
+require_once 'Zend/Search/Lucene/Search/Similarity/Default.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Search_Similarity
+{
+    /**
+     * The Similarity implementation used by default.
+     *
+     * @var Zend_Search_Lucene_Search_Similarity
+     */
+    private static $_defaultImpl;
+
+    /**
+     * Cache of decoded bytes.
+     * Array of floats
+     *
+     * @var array
+     */
+    private static $_normTable = array( 0   => 0.0,
+                                        1   => 5.820766E-10,
+                                        2   => 6.9849193E-10,
+                                        3   => 8.1490725E-10,
+                                        4   => 9.313226E-10,
+                                        5   => 1.1641532E-9,
+                                        6   => 1.3969839E-9,
+                                        7   => 1.6298145E-9,
+                                        8   => 1.8626451E-9,
+                                        9   => 2.3283064E-9,
+                                        10  => 2.7939677E-9,
+                                        11  => 3.259629E-9,
+                                        12  => 3.7252903E-9,
+                                        13  => 4.656613E-9,
+                                        14  => 5.5879354E-9,
+                                        15  => 6.519258E-9,
+                                        16  => 7.4505806E-9,
+                                        17  => 9.313226E-9,
+                                        18  => 1.1175871E-8,
+                                        19  => 1.3038516E-8,
+                                        20  => 1.4901161E-8,
+                                        21  => 1.8626451E-8,
+                                        22  => 2.2351742E-8,
+                                        23  => 2.6077032E-8,
+                                        24  => 2.9802322E-8,
+                                        25  => 3.7252903E-8,
+                                        26  => 4.4703484E-8,
+                                        27  => 5.2154064E-8,
+                                        28  => 5.9604645E-8,
+                                        29  => 7.4505806E-8,
+                                        30  => 8.940697E-8,
+                                        31  => 1.0430813E-7,
+                                        32  => 1.1920929E-7,
+                                        33  => 1.4901161E-7,
+                                        34  => 1.7881393E-7,
+                                        35  => 2.0861626E-7,
+                                        36  => 2.3841858E-7,
+                                        37  => 2.9802322E-7,
+                                        38  => 3.5762787E-7,
+                                        39  => 4.172325E-7,
+                                        40  => 4.7683716E-7,
+                                        41  => 5.9604645E-7,
+                                        42  => 7.1525574E-7,
+                                        43  => 8.34465E-7,
+                                        44  => 9.536743E-7,
+                                        45  => 1.1920929E-6,
+                                        46  => 1.4305115E-6,
+                                        47  => 1.66893E-6,
+                                        48  => 1.9073486E-6,
+                                        49  => 2.3841858E-6,
+                                        50  => 2.861023E-6,
+                                        51  => 3.33786E-6,
+                                        52  => 3.8146973E-6,
+                                        53  => 4.7683716E-6,
+                                        54  => 5.722046E-6,
+                                        55  => 6.67572E-6,
+                                        56  => 7.6293945E-6,
+                                        57  => 9.536743E-6,
+                                        58  => 1.1444092E-5,
+                                        59  => 1.335144E-5,
+                                        60  => 1.5258789E-5,
+                                        61  => 1.9073486E-5,
+                                        62  => 2.2888184E-5,
+                                        63  => 2.670288E-5,
+                                        64  => 3.0517578E-5,
+                                        65  => 3.8146973E-5,
+                                        66  => 4.5776367E-5,
+                                        67  => 5.340576E-5,
+                                        68  => 6.1035156E-5,
+                                        69  => 7.6293945E-5,
+                                        70  => 9.1552734E-5,
+                                        71  => 1.0681152E-4,
+                                        72  => 1.2207031E-4,
+                                        73  => 1.5258789E-4,
+                                        74  => 1.8310547E-4,
+                                        75  => 2.1362305E-4,
+                                        76  => 2.4414062E-4,
+                                        77  => 3.0517578E-4,
+                                        78  => 3.6621094E-4,
+                                        79  => 4.272461E-4,
+                                        80  => 4.8828125E-4,
+                                        81  => 6.1035156E-4,
+                                        82  => 7.324219E-4,
+                                        83  => 8.544922E-4,
+                                        84  => 9.765625E-4,
+                                        85  => 0.0012207031,
+                                        86  => 0.0014648438,
+                                        87  => 0.0017089844,
+                                        88  => 0.001953125,
+                                        89  => 0.0024414062,
+                                        90  => 0.0029296875,
+                                        91  => 0.0034179688,
+                                        92  => 0.00390625,
+                                        93  => 0.0048828125,
+                                        94  => 0.005859375,
+                                        95  => 0.0068359375,
+                                        96  => 0.0078125,
+                                        97  => 0.009765625,
+                                        98  => 0.01171875,
+                                        99  => 0.013671875,
+                                        100 => 0.015625,
+                                        101 => 0.01953125,
+                                        102 => 0.0234375,
+                                        103 => 0.02734375,
+                                        104 => 0.03125,
+                                        105 => 0.0390625,
+                                        106 => 0.046875,
+                                        107 => 0.0546875,
+                                        108 => 0.0625,
+                                        109 => 0.078125,
+                                        110 => 0.09375,
+                                        111 => 0.109375,
+                                        112 => 0.125,
+                                        113 => 0.15625,
+                                        114 => 0.1875,
+                                        115 => 0.21875,
+                                        116 => 0.25,
+                                        117 => 0.3125,
+                                        118 => 0.375,
+                                        119 => 0.4375,
+                                        120 => 0.5,
+                                        121 => 0.625,
+                                        122 => 0.75,
+                                        123 => 0.875,
+                                        124 => 1.0,
+                                        125 => 1.25,
+                                        126 => 1.5,
+                                        127 => 1.75,
+                                        128 => 2.0,
+                                        129 => 2.5,
+                                        130 => 3.0,
+                                        131 => 3.5,
+                                        132 => 4.0,
+                                        133 => 5.0,
+                                        134 => 6.0,
+                                        135 => 7.0,
+                                        136 => 8.0,
+                                        137 => 10.0,
+                                        138 => 12.0,
+                                        139 => 14.0,
+                                        140 => 16.0,
+                                        141 => 20.0,
+                                        142 => 24.0,
+                                        143 => 28.0,
+                                        144 => 32.0,
+                                        145 => 40.0,
+                                        146 => 48.0,
+                                        147 => 56.0,
+                                        148 => 64.0,
+                                        149 => 80.0,
+                                        150 => 96.0,
+                                        151 => 112.0,
+                                        152 => 128.0,
+                                        153 => 160.0,
+                                        154 => 192.0,
+                                        155 => 224.0,
+                                        156 => 256.0,
+                                        157 => 320.0,
+                                        158 => 384.0,
+                                        159 => 448.0,
+                                        160 => 512.0,
+                                        161 => 640.0,
+                                        162 => 768.0,
+                                        163 => 896.0,
+                                        164 => 1024.0,
+                                        165 => 1280.0,
+                                        166 => 1536.0,
+                                        167 => 1792.0,
+                                        168 => 2048.0,
+                                        169 => 2560.0,
+                                        170 => 3072.0,
+                                        171 => 3584.0,
+                                        172 => 4096.0,
+                                        173 => 5120.0,
+                                        174 => 6144.0,
+                                        175 => 7168.0,
+                                        176 => 8192.0,
+                                        177 => 10240.0,
+                                        178 => 12288.0,
+                                        179 => 14336.0,
+                                        180 => 16384.0,
+                                        181 => 20480.0,
+                                        182 => 24576.0,
+                                        183 => 28672.0,
+                                        184 => 32768.0,
+                                        185 => 40960.0,
+                                        186 => 49152.0,
+                                        187 => 57344.0,
+                                        188 => 65536.0,
+                                        189 => 81920.0,
+                                        190 => 98304.0,
+                                        191 => 114688.0,
+                                        192 => 131072.0,
+                                        193 => 163840.0,
+                                        194 => 196608.0,
+                                        195 => 229376.0,
+                                        196 => 262144.0,
+                                        197 => 327680.0,
+                                        198 => 393216.0,
+                                        199 => 458752.0,
+                                        200 => 524288.0,
+                                        201 => 655360.0,
+                                        202 => 786432.0,
+                                        203 => 917504.0,
+                                        204 => 1048576.0,
+                                        205 => 1310720.0,
+                                        206 => 1572864.0,
+                                        207 => 1835008.0,
+                                        208 => 2097152.0,
+                                        209 => 2621440.0,
+                                        210 => 3145728.0,
+                                        211 => 3670016.0,
+                                        212 => 4194304.0,
+                                        213 => 5242880.0,
+                                        214 => 6291456.0,
+                                        215 => 7340032.0,
+                                        216 => 8388608.0,
+                                        217 => 1.048576E7,
+                                        218 => 1.2582912E7,
+                                        219 => 1.4680064E7,
+                                        220 => 1.6777216E7,
+                                        221 => 2.097152E7,
+                                        222 => 2.5165824E7,
+                                        223 => 2.9360128E7,
+                                        224 => 3.3554432E7,
+                                        225 => 4.194304E7,
+                                        226 => 5.0331648E7,
+                                        227 => 5.8720256E7,
+                                        228 => 6.7108864E7,
+                                        229 => 8.388608E7,
+                                        230 => 1.00663296E8,
+                                        231 => 1.17440512E8,
+                                        232 => 1.34217728E8,
+                                        233 => 1.6777216E8,
+                                        234 => 2.01326592E8,
+                                        235 => 2.34881024E8,
+                                        236 => 2.68435456E8,
+                                        237 => 3.3554432E8,
+                                        238 => 4.02653184E8,
+                                        239 => 4.69762048E8,
+                                        240 => 5.3687091E8,
+                                        241 => 6.7108864E8,
+                                        242 => 8.0530637E8,
+                                        243 => 9.395241E8,
+                                        244 => 1.07374182E9,
+                                        245 => 1.34217728E9,
+                                        246 => 1.61061274E9,
+                                        247 => 1.87904819E9,
+                                        248 => 2.14748365E9,
+                                        249 => 2.68435456E9,
+                                        250 => 3.22122547E9,
+                                        251 => 3.75809638E9,
+                                        252 => 4.2949673E9,
+                                        253 => 5.3687091E9,
+                                        254 => 6.4424509E9,
+                                        255 => 7.5161928E9 );
+
+
+    /**
+     * Set the default Similarity implementation used by indexing and search
+     * code.
+     *
+     * @param Zend_Search_Lucene_Search_Similarity $similarity
+     */
+    public static function setDefault(Zend_Search_Lucene_Search_Similarity $similarity)
+    {
+        self::$_defaultImpl = $similarity;
+    }
+
+
+    /**
+     * Return the default Similarity implementation used by indexing and search
+     * code.
+     *
+     * @return Zend_Search_Lucene_Search_Similarity
+     */
+    public static function getDefault()
+    {
+        if (!self::$_defaultImpl instanceof Zend_Search_Lucene_Search_Similarity) {
+            self::$_defaultImpl = new Zend_Search_Lucene_Search_Similarity_Default();
+        }
+
+        return self::$_defaultImpl;
+    }
+
+
+    /**
+     * Computes the normalization value for a field given the total number of
+     * terms contained in a field.  These values, together with field boosts, are
+     * stored in an index and multipled into scores for hits on each field by the
+     * search code.
+     *
+     * Matches in longer fields are less precise, so implemenations of this
+     * method usually return smaller values when 'numTokens' is large,
+     * and larger values when 'numTokens' is small.
+     *
+     * That these values are computed under
+     * IndexWriter::addDocument(Document) and stored then using
+     * encodeNorm(float).  Thus they have limited precision, and documents
+     * must be re-indexed if this method is altered.
+     *
+     * fieldName - name of field
+     * numTokens - the total number of tokens contained in fields named
+     *             'fieldName' of 'doc'.
+     * Returns a normalization factor for hits on this field of this document
+     *
+     * @param string $fieldName
+     * @param integer $numTokens
+     * @return float
+     */
+    abstract public function lengthNorm($fieldName, $numTokens);
+
+    /**
+     * Computes the normalization value for a query given the sum of the squared
+     * weights of each of the query terms.  This value is then multipled into the
+     * weight of each query term.
+     *
+     * This does not affect ranking, but rather just attempts to make scores
+     * from different queries comparable.
+     *
+     * sumOfSquaredWeights - the sum of the squares of query term weights
+     * Returns a normalization factor for query weights
+     *
+     * @param float $sumOfSquaredWeights
+     * @return float
+     */
+    abstract public function queryNorm($sumOfSquaredWeights);
+
+
+    /**
+     *  Decodes a normalization factor stored in an index.
+     *
+     * @param integer $byte
+     * @return float
+     */
+    public static function decodeNorm($byte)
+    {
+        return self::$_normTable[$byte & 0xFF];
+    }
+
+
+    /**
+     * Encodes a normalization factor for storage in an index.
+     *
+     * The encoding uses a five-bit exponent and three-bit mantissa, thus
+     * representing values from around 7x10^9 to 2x10^-9 with about one
+     * significant decimal digit of accuracy.  Zero is also represented.
+     * Negative numbers are rounded up to zero.  Values too large to represent
+     * are rounded down to the largest representable value.  Positive values too
+     * small to represent are rounded up to the smallest positive representable
+     * value.
+     *
+     * @param float $f
+     * @return integer
+     */
+    static function encodeNorm($f)
+    {
+      return self::_floatToByte($f);
+    }
+
+    /**
+     * Float to byte conversion
+     *
+     * @param integer $b
+     * @return float
+     */
+    private static function _floatToByte($f)
+    {
+        // round negatives up to zero
+        if ($f <= 0.0) {
+            return 0;
+        }
+
+        // search for appropriate value
+        $lowIndex = 0;
+        $highIndex = 255;
+        while ($highIndex >= $lowIndex) {
+            // $mid = ($highIndex - $lowIndex)/2;
+            $mid = ($highIndex + $lowIndex) >> 1;
+            $delta = $f - self::$_normTable[$mid];
+
+            if ($delta < 0) {
+                $highIndex = $mid-1;
+            } elseif ($delta > 0) {
+                $lowIndex  = $mid+1;
+            } else {
+                return $mid; // We got it!
+            }
+        }
+
+        // round to closest value
+        if ($highIndex != 255 &&
+            $f - self::$_normTable[$highIndex] > self::$_normTable[$highIndex+1] - $f ) {
+            return $highIndex + 1;
+        } else {
+            return $highIndex;
+        }
+    }
+
+
+    /**
+     * Computes a score factor based on a term or phrase's frequency in a
+     * document.  This value is multiplied by the idf(Term, Searcher)
+     * factor for each term in the query and these products are then summed to
+     * form the initial score for a document.
+     *
+     * Terms and phrases repeated in a document indicate the topic of the
+     * document, so implementations of this method usually return larger values
+     * when 'freq' is large, and smaller values when 'freq'
+     * is small.
+     *
+     * freq - the frequency of a term within a document
+     * Returns a score factor based on a term's within-document frequency
+     *
+     * @param float $freq
+     * @return float
+     */
+    abstract public function tf($freq);
+
+    /**
+     * Computes the amount of a sloppy phrase match, based on an edit distance.
+     * This value is summed for each sloppy phrase match in a document to form
+     * the frequency that is passed to tf(float).
+     *
+     * A phrase match with a small edit distance to a document passage more
+     * closely matches the document, so implementations of this method usually
+     * return larger values when the edit distance is small and smaller values
+     * when it is large.
+     *
+     * distance - the edit distance of this sloppy phrase match
+     * Returns the frequency increment for this match
+     *
+     * @param integer $distance
+     * @return float
+     */
+    abstract public function sloppyFreq($distance);
+
+
+    /**
+     * Computes a score factor for a simple term or a phrase.
+     *
+     * The default implementation is:
+     *   return idfFreq(searcher.docFreq(term), searcher.maxDoc());
+     *
+     * input - the term in question or array of terms
+     * reader - reader the document collection being searched
+     * Returns a score factor for the term
+     *
+     * @param mixed $input
+     * @param Zend_Search_Lucene_Interface $reader
+     * @return a score factor for the term
+     */
+    public function idf($input, Zend_Search_Lucene_Interface $reader)
+    {
+        if (!is_array($input)) {
+            return $this->idfFreq($reader->docFreq($input), $reader->count());
+        } else {
+            $idf = 0.0;
+            foreach ($input as $term) {
+                $idf += $this->idfFreq($reader->docFreq($term), $reader->count());
+            }
+            return $idf;
+        }
+    }
+
+    /**
+     * Computes a score factor based on a term's document frequency (the number
+     * of documents which contain the term).  This value is multiplied by the
+     * tf(int) factor for each term in the query and these products are
+     * then summed to form the initial score for a document.
+     *
+     * Terms that occur in fewer documents are better indicators of topic, so
+     * implemenations of this method usually return larger values for rare terms,
+     * and smaller values for common terms.
+     *
+     * docFreq - the number of documents which contain the term
+     * numDocs - the total number of documents in the collection
+     * Returns a score factor based on the term's document frequency
+     *
+     * @param integer $docFreq
+     * @param integer $numDocs
+     * @return float
+     */
+    abstract public function idfFreq($docFreq, $numDocs);
+
+    /**
+     * Computes a score factor based on the fraction of all query terms that a
+     * document contains.  This value is multiplied into scores.
+     *
+     * The presence of a large portion of the query terms indicates a better
+     * match with the query, so implemenations of this method usually return
+     * larger values when the ratio between these parameters is large and smaller
+     * values when the ratio between them is small.
+     *
+     * overlap - the number of query terms matched in the document
+     * maxOverlap - the total number of terms in the query
+     * Returns a score factor based on term overlap with the query
+     *
+     * @param integer $overlap
+     * @param integer $maxOverlap
+     * @return float
+     */
+    abstract public function coord($overlap, $maxOverlap);
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Similarity/Default.php b/inc/lib/Zend/Search/Lucene/Search/Similarity/Default.php
new file mode 100644 (file)
index 0000000..8bcd3f0
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Default.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Similarity */
+require_once 'Zend/Search/Lucene/Search/Similarity.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Similarity_Default extends Zend_Search_Lucene_Search_Similarity
+{
+
+    /**
+     * Implemented as '1/sqrt(numTerms)'.
+     *
+     * @param string $fieldName
+     * @param integer $numTerms
+     * @return float
+     */
+    public function lengthNorm($fieldName, $numTerms)
+    {
+        if ($numTerms == 0) {
+            return 1E10;
+        }
+
+        return 1.0/sqrt($numTerms);
+    }
+
+    /**
+     * Implemented as '1/sqrt(sumOfSquaredWeights)'.
+     *
+     * @param float $sumOfSquaredWeights
+     * @return float
+     */
+    public function queryNorm($sumOfSquaredWeights)
+    {
+        return 1.0/sqrt($sumOfSquaredWeights);
+    }
+
+    /**
+     * Implemented as 'sqrt(freq)'.
+     *
+     * @param float $freq
+     * @return float
+     */
+    public function tf($freq)
+    {
+        return sqrt($freq);
+    }
+
+    /**
+     * Implemented as '1/(distance + 1)'.
+     *
+     * @param integer $distance
+     * @return float
+     */
+    public function sloppyFreq($distance)
+    {
+        return 1.0/($distance + 1);
+    }
+
+    /**
+     * Implemented as 'log(numDocs/(docFreq+1)) + 1'.
+     *
+     * @param integer $docFreq
+     * @param integer $numDocs
+     * @return float
+     */
+    public function idfFreq($docFreq, $numDocs)
+    {
+        return log($numDocs/(float)($docFreq+1)) + 1.0;
+    }
+
+    /**
+     * Implemented as 'overlap/maxOverlap'.
+     *
+     * @param integer $overlap
+     * @param integer $maxOverlap
+     * @return float
+     */
+    public function coord($overlap, $maxOverlap)
+    {
+        return $overlap/(float)$maxOverlap;
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Search/Weight.php b/inc/lib/Zend/Search/Lucene/Search/Weight.php
new file mode 100644 (file)
index 0000000..474c54e
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Weight.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * Calculate query weights and build query scorers.
+ *
+ * A Weight is constructed by a query Query->createWeight().
+ * The sumOfSquaredWeights() method is then called on the top-level
+ * query to compute the query normalization factor Similarity->queryNorm(float).
+ * This factor is then passed to normalize(float).  At this point the weighting
+ * is complete.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Search_Weight
+{
+    /**
+     * Normalization factor.
+     * This value is stored only for query expanation purpose and not used in any other place
+     *
+     * @var float
+     */
+    protected $_queryNorm;
+
+    /**
+     * Weight value
+     *
+     * Weight value may be initialized in sumOfSquaredWeights() or normalize()
+     * because they both are invoked either in Query::_initWeight (for top-level query) or
+     * in corresponding methods of parent query's weights
+     *
+     * @var float
+     */
+    protected $_value;
+
+
+    /**
+     * The weight for this query.
+     *
+     * @return float
+     */
+    public function getValue()
+    {
+        return $this->_value;
+    }
+
+    /**
+     * The sum of squared weights of contained query clauses.
+     *
+     * @return float
+     */
+    abstract public function sumOfSquaredWeights();
+
+    /**
+     * Assigns the query normalization factor to this.
+     *
+     * @param $norm
+     */
+    abstract public function normalize($norm);
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Weight/Boolean.php b/inc/lib/Zend/Search/Lucene/Search/Weight/Boolean.php
new file mode 100644 (file)
index 0000000..535411c
--- /dev/null
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Boolean.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Weight */
+require_once 'Zend/Search/Lucene/Search/Weight.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Weight_Boolean extends Zend_Search_Lucene_Search_Weight
+{
+    /**
+     * IndexReader.
+     *
+     * @var Zend_Search_Lucene_Interface
+     */
+    private $_reader;
+
+    /**
+     * The query that this concerns.
+     *
+     * @var Zend_Search_Lucene_Search_Query
+     */
+    private $_query;
+
+    /**
+     * Queries weights
+     * Array of Zend_Search_Lucene_Search_Weight
+     *
+     * @var array
+     */
+    private $_weights;
+
+
+    /**
+     * Zend_Search_Lucene_Search_Weight_Boolean constructor
+     * query - the query that this concerns.
+     * reader - index reader
+     *
+     * @param Zend_Search_Lucene_Search_Query $query
+     * @param Zend_Search_Lucene_Interface    $reader
+     */
+    public function __construct(Zend_Search_Lucene_Search_Query $query,
+                                Zend_Search_Lucene_Interface    $reader)
+    {
+        $this->_query   = $query;
+        $this->_reader  = $reader;
+        $this->_weights = array();
+
+        $signs = $query->getSigns();
+
+        foreach ($query->getSubqueries() as $num => $subquery) {
+            if ($signs === null || $signs[$num] === null || $signs[$num]) {
+                $this->_weights[$num] = $subquery->createWeight($reader);
+            }
+        }
+    }
+
+
+    /**
+     * The weight for this query
+     * Standard Weight::$_value is not used for boolean queries
+     *
+     * @return float
+     */
+    public function getValue()
+    {
+        return $this->_query->getBoost();
+    }
+
+
+    /**
+     * The sum of squared weights of contained query clauses.
+     *
+     * @return float
+     */
+    public function sumOfSquaredWeights()
+    {
+        $sum = 0;
+        foreach ($this->_weights as $weight) {
+            // sum sub weights
+            $sum += $weight->sumOfSquaredWeights();
+        }
+
+        // boost each sub-weight
+        $sum *= $this->_query->getBoost() * $this->_query->getBoost();
+
+        // check for empty query (like '-something -another')
+        if ($sum == 0) {
+            $sum = 1.0;
+        }
+        return $sum;
+    }
+
+
+    /**
+     * Assigns the query normalization factor to this.
+     *
+     * @param float $queryNorm
+     */
+    public function normalize($queryNorm)
+    {
+        // incorporate boost
+        $queryNorm *= $this->_query->getBoost();
+
+        foreach ($this->_weights as $weight) {
+            $weight->normalize($queryNorm);
+        }
+    }
+}
+
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Weight/Empty.php b/inc/lib/Zend/Search/Lucene/Search/Weight/Empty.php
new file mode 100644 (file)
index 0000000..c1f525f
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Empty.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+
+/** Zend_Search_Lucene_Search_Weight */
+require_once 'Zend/Search/Lucene/Search/Weight.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Weight_Empty extends Zend_Search_Lucene_Search_Weight
+{
+    /**
+     * The sum of squared weights of contained query clauses.
+     *
+     * @return float
+     */
+    public function sumOfSquaredWeights()
+    {
+        return 1;
+    }
+
+
+    /**
+     * Assigns the query normalization factor to this.
+     *
+     * @param float $queryNorm
+     */
+    public function normalize($queryNorm)
+    {
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Weight/MultiTerm.php b/inc/lib/Zend/Search/Lucene/Search/Weight/MultiTerm.php
new file mode 100644 (file)
index 0000000..445e57b
--- /dev/null
@@ -0,0 +1,138 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: MultiTerm.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Weight */
+require_once 'Zend/Search/Lucene/Search/Weight.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Weight_MultiTerm extends Zend_Search_Lucene_Search_Weight
+{
+    /**
+     * IndexReader.
+     *
+     * @var Zend_Search_Lucene_Interface
+     */
+    private $_reader;
+
+    /**
+     * The query that this concerns.
+     *
+     * @var Zend_Search_Lucene_Search_Query
+     */
+    private $_query;
+
+    /**
+     * Query terms weights
+     * Array of Zend_Search_Lucene_Search_Weight_Term
+     *
+     * @var array
+     */
+    private $_weights;
+
+
+    /**
+     * Zend_Search_Lucene_Search_Weight_MultiTerm constructor
+     * query - the query that this concerns.
+     * reader - index reader
+     *
+     * @param Zend_Search_Lucene_Search_Query $query
+     * @param Zend_Search_Lucene_Interface    $reader
+     */
+    public function __construct(Zend_Search_Lucene_Search_Query $query,
+                                Zend_Search_Lucene_Interface    $reader)
+    {
+        $this->_query   = $query;
+        $this->_reader  = $reader;
+        $this->_weights = array();
+
+        $signs = $query->getSigns();
+
+        foreach ($query->getTerms() as $id => $term) {
+            if ($signs === null || $signs[$id] === null || $signs[$id]) {
+                $this->_weights[$id] = new Zend_Search_Lucene_Search_Weight_Term($term, $query, $reader);
+                $query->setWeight($id, $this->_weights[$id]);
+            }
+        }
+    }
+
+
+    /**
+     * The weight for this query
+     * Standard Weight::$_value is not used for boolean queries
+     *
+     * @return float
+     */
+    public function getValue()
+    {
+        return $this->_query->getBoost();
+    }
+
+
+    /**
+     * The sum of squared weights of contained query clauses.
+     *
+     * @return float
+     */
+    public function sumOfSquaredWeights()
+    {
+        $sum = 0;
+        foreach ($this->_weights as $weight) {
+            // sum sub weights
+            $sum += $weight->sumOfSquaredWeights();
+        }
+
+        // boost each sub-weight
+        $sum *= $this->_query->getBoost() * $this->_query->getBoost();
+
+        // check for empty query (like '-something -another')
+        if ($sum == 0) {
+            $sum = 1.0;
+        }
+        return $sum;
+    }
+
+
+    /**
+     * Assigns the query normalization factor to this.
+     *
+     * @param float $queryNorm
+     */
+    public function normalize($queryNorm)
+    {
+        // incorporate boost
+        $queryNorm *= $this->_query->getBoost();
+
+        foreach ($this->_weights as $weight) {
+            $weight->normalize($queryNorm);
+        }
+    }
+}
+
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Weight/Phrase.php b/inc/lib/Zend/Search/Lucene/Search/Weight/Phrase.php
new file mode 100644 (file)
index 0000000..34656cc
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Phrase.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * Zend_Search_Lucene_Search_Weight
+ */
+require_once 'Zend/Search/Lucene/Search/Weight.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Weight_Phrase extends Zend_Search_Lucene_Search_Weight
+{
+    /**
+     * IndexReader.
+     *
+     * @var Zend_Search_Lucene_Interface
+     */
+    private $_reader;
+
+    /**
+     * The query that this concerns.
+     *
+     * @var Zend_Search_Lucene_Search_Query_Phrase
+     */
+    private $_query;
+
+    /**
+     * Score factor
+     *
+     * @var float
+     */
+    private $_idf;
+
+    /**
+     * Zend_Search_Lucene_Search_Weight_Phrase constructor
+     *
+     * @param Zend_Search_Lucene_Search_Query_Phrase $query
+     * @param Zend_Search_Lucene_Interface           $reader
+     */
+    public function __construct(Zend_Search_Lucene_Search_Query_Phrase $query,
+                                Zend_Search_Lucene_Interface           $reader)
+    {
+        $this->_query  = $query;
+        $this->_reader = $reader;
+    }
+
+    /**
+     * The sum of squared weights of contained query clauses.
+     *
+     * @return float
+     */
+    public function sumOfSquaredWeights()
+    {
+        // compute idf
+        $this->_idf = $this->_reader->getSimilarity()->idf($this->_query->getTerms(), $this->_reader);
+
+        // compute query weight
+        $this->_queryWeight = $this->_idf * $this->_query->getBoost();
+
+        // square it
+        return $this->_queryWeight * $this->_queryWeight;
+    }
+
+
+    /**
+     * Assigns the query normalization factor to this.
+     *
+     * @param float $queryNorm
+     */
+    public function normalize($queryNorm)
+    {
+        $this->_queryNorm = $queryNorm;
+
+        // normalize query weight
+        $this->_queryWeight *= $queryNorm;
+
+        // idf for documents
+        $this->_value = $this->_queryWeight * $this->_idf;
+    }
+}
+
+
diff --git a/inc/lib/Zend/Search/Lucene/Search/Weight/Term.php b/inc/lib/Zend/Search/Lucene/Search/Weight/Term.php
new file mode 100644 (file)
index 0000000..13585e3
--- /dev/null
@@ -0,0 +1,125 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Term.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Search_Weight */
+require_once 'Zend/Search/Lucene/Search/Weight.php';
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Search
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Search_Weight_Term extends Zend_Search_Lucene_Search_Weight
+{
+    /**
+     * IndexReader.
+     *
+     * @var Zend_Search_Lucene_Interface
+     */
+    private $_reader;
+
+    /**
+     * Term
+     *
+     * @var Zend_Search_Lucene_Index_Term
+     */
+    private $_term;
+
+    /**
+     * The query that this concerns.
+     *
+     * @var Zend_Search_Lucene_Search_Query
+     */
+    private $_query;
+
+    /**
+     * Score factor
+     *
+     * @var float
+     */
+    private $_idf;
+
+    /**
+     * Query weight
+     *
+     * @var float
+     */
+    private $_queryWeight;
+
+
+    /**
+     * Zend_Search_Lucene_Search_Weight_Term constructor
+     * reader - index reader
+     *
+     * @param Zend_Search_Lucene_Index_Term   $term
+     * @param Zend_Search_Lucene_Search_Query $query
+     * @param Zend_Search_Lucene_Interface    $reader
+     */
+    public function __construct(Zend_Search_Lucene_Index_Term   $term,
+                                Zend_Search_Lucene_Search_Query $query,
+                                Zend_Search_Lucene_Interface    $reader)
+    {
+        $this->_term   = $term;
+        $this->_query  = $query;
+        $this->_reader = $reader;
+    }
+
+
+    /**
+     * The sum of squared weights of contained query clauses.
+     *
+     * @return float
+     */
+    public function sumOfSquaredWeights()
+    {
+        // compute idf
+        $this->_idf = $this->_reader->getSimilarity()->idf($this->_term, $this->_reader);
+
+        // compute query weight
+        $this->_queryWeight = $this->_idf * $this->_query->getBoost();
+
+        // square it
+        return $this->_queryWeight * $this->_queryWeight;
+    }
+
+
+    /**
+     * Assigns the query normalization factor to this.
+     *
+     * @param float $queryNorm
+     */
+    public function normalize($queryNorm)
+    {
+        $this->_queryNorm = $queryNorm;
+
+        // normalize query weight
+        $this->_queryWeight *= $queryNorm;
+
+        // idf for documents
+        $this->_value = $this->_queryWeight * $this->_idf;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Storage/Directory.php b/inc/lib/Zend/Search/Lucene/Storage/Directory.php
new file mode 100644 (file)
index 0000000..e124ad3
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Directory.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Storage_Directory
+{
+
+    /**
+     * Closes the store.
+     *
+     * @return void
+     */
+    abstract public function close();
+
+    /**
+     * Returns an array of strings, one for each file in the directory.
+     *
+     * @return array
+     */
+    abstract public function fileList();
+
+    /**
+     * Creates a new, empty file in the directory with the given $filename.
+     *
+     * @param string $filename
+     * @return Zend_Search_Lucene_Storage_File
+     */
+    abstract public function createFile($filename);
+
+
+    /**
+     * Removes an existing $filename in the directory.
+     *
+     * @param string $filename
+     * @return void
+     */
+    abstract public function deleteFile($filename);
+
+    /**
+     * Purge file if it's cached by directory object
+     * 
+     * Method is used to prevent 'too many open files' error
+     *
+     * @param string $filename
+     * @return void
+     */
+    abstract public function purgeFile($filename);
+    
+    /**
+     * Returns true if a file with the given $filename exists.
+     *
+     * @param string $filename
+     * @return boolean
+     */
+    abstract public function fileExists($filename);
+
+
+    /**
+     * Returns the length of a $filename in the directory.
+     *
+     * @param string $filename
+     * @return integer
+     */
+    abstract public function fileLength($filename);
+
+
+    /**
+     * Returns the UNIX timestamp $filename was last modified.
+     *
+     * @param string $filename
+     * @return integer
+     */
+    abstract public function fileModified($filename);
+
+
+    /**
+     * Renames an existing file in the directory.
+     *
+     * @param string $from
+     * @param string $to
+     * @return void
+     */
+    abstract public function renameFile($from, $to);
+
+
+    /**
+     * Sets the modified time of $filename to now.
+     *
+     * @param string $filename
+     * @return void
+     */
+    abstract public function touchFile($filename);
+
+
+    /**
+     * Returns a Zend_Search_Lucene_Storage_File object for a given $filename in the directory.
+     *
+     * If $shareHandler option is true, then file handler can be shared between File Object
+     * requests. It speed-ups performance, but makes problems with file position.
+     * Shared handler are good for short atomic requests.
+     * Non-shared handlers are useful for stream file reading (especial for compound files).
+     *
+     * @param string $filename
+     * @param boolean $shareHandler
+     * @return Zend_Search_Lucene_Storage_File
+     */
+    abstract public function getFileObject($filename, $shareHandler = true);
+
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Storage/Directory/Filesystem.php b/inc/lib/Zend/Search/Lucene/Storage/Directory/Filesystem.php
new file mode 100644 (file)
index 0000000..f44aaa6
--- /dev/null
@@ -0,0 +1,363 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Filesystem.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+
+/** Zend_Search_Lucene_Storage_Directory */
+require_once 'Zend/Search/Lucene/Storage/Directory.php';
+
+/** Zend_Search_Lucene_Storage_File_Filesystem */
+require_once 'Zend/Search/Lucene/Storage/File/Filesystem.php';
+
+
+/**
+ * FileSystem implementation of Directory abstraction.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Storage_Directory_Filesystem extends Zend_Search_Lucene_Storage_Directory
+{
+    /**
+     * Filesystem path to the directory
+     *
+     * @var string
+     */
+    protected $_dirPath = null;
+
+    /**
+     * Cache for Zend_Search_Lucene_Storage_File_Filesystem objects
+     * Array: filename => Zend_Search_Lucene_Storage_File object
+     *
+     * @var array
+     * @throws Zend_Search_Lucene_Exception
+     */
+    protected $_fileHandlers;
+
+    /**
+     * Default file permissions
+     *
+     * @var integer
+     */
+    protected static $_defaultFilePermissions = 0666;
+
+
+    /**
+     * Get default file permissions
+     *
+     * @return integer
+     */
+    public static function getDefaultFilePermissions()
+    {
+        return self::$_defaultFilePermissions;
+    }
+
+    /**
+     * Set default file permissions
+     *
+     * @param integer $mode
+     */
+    public static function setDefaultFilePermissions($mode)
+    {
+        self::$_defaultFilePermissions = $mode;
+    }
+
+
+    /**
+     * Utility function to recursive directory creation
+     *
+     * @param string $dir
+     * @param integer $mode
+     * @param boolean $recursive
+     * @return boolean
+     */
+
+    public static function mkdirs($dir, $mode = 0777, $recursive = true)
+    {
+        if (($dir === null) || $dir === '') {
+            return false;
+        }
+        if (is_dir($dir) || $dir === '/') {
+            return true;
+        }
+        if (self::mkdirs(dirname($dir), $mode, $recursive)) {
+            return mkdir($dir, $mode);
+        }
+        return false;
+    }
+
+
+    /**
+     * Object constructor
+     * Checks if $path is a directory or tries to create it.
+     *
+     * @param string $path
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function __construct($path)
+    {
+        if (!is_dir($path)) {
+            if (file_exists($path)) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Path exists, but it\'s not a directory');
+            } else {
+                if (!self::mkdirs($path)) {
+                    require_once 'Zend/Search/Lucene/Exception.php';
+                    throw new Zend_Search_Lucene_Exception("Can't create directory '$path'.");
+                }
+            }
+        }
+        $this->_dirPath = $path;
+        $this->_fileHandlers = array();
+    }
+
+
+    /**
+     * Closes the store.
+     *
+     * @return void
+     */
+    public function close()
+    {
+        foreach ($this->_fileHandlers as $fileObject) {
+            $fileObject->close();
+        }
+
+        $this->_fileHandlers = array();
+    }
+
+
+    /**
+     * Returns an array of strings, one for each file in the directory.
+     *
+     * @return array
+     */
+    public function fileList()
+    {
+        $result = array();
+
+        $dirContent = opendir( $this->_dirPath );
+        while (($file = readdir($dirContent)) !== false) {
+            if (($file == '..')||($file == '.'))   continue;
+
+            if( !is_dir($this->_dirPath . '/' . $file) ) {
+                $result[] = $file;
+            }
+        }
+        closedir($dirContent);
+
+        return $result;
+    }
+
+    /**
+     * Creates a new, empty file in the directory with the given $filename.
+     *
+     * @param string $filename
+     * @return Zend_Search_Lucene_Storage_File
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function createFile($filename)
+    {
+        if (isset($this->_fileHandlers[$filename])) {
+            $this->_fileHandlers[$filename]->close();
+        }
+        unset($this->_fileHandlers[$filename]);
+        $this->_fileHandlers[$filename] = new Zend_Search_Lucene_Storage_File_Filesystem($this->_dirPath . '/' . $filename, 'w+b');
+
+        // Set file permissions, but don't care about any possible failures, since file may be already
+        // created by anther user which has to care about right permissions
+        @chmod($this->_dirPath . '/' . $filename, self::$_defaultFilePermissions);
+
+        return $this->_fileHandlers[$filename];
+    }
+
+
+    /**
+     * Removes an existing $filename in the directory.
+     *
+     * @param string $filename
+     * @return void
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function deleteFile($filename)
+    {
+        if (isset($this->_fileHandlers[$filename])) {
+            $this->_fileHandlers[$filename]->close();
+        }
+        unset($this->_fileHandlers[$filename]);
+
+        global $php_errormsg;
+        $trackErrors = ini_get('track_errors'); ini_set('track_errors', '1');
+        if (!@unlink($this->_dirPath . '/' . $filename)) {
+            ini_set('track_errors', $trackErrors);
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Can\'t delete file: ' . $php_errormsg);
+        }
+        ini_set('track_errors', $trackErrors);
+    }
+
+    /**
+     * Purge file if it's cached by directory object
+     *
+     * Method is used to prevent 'too many open files' error
+     *
+     * @param string $filename
+     * @return void
+     */
+    public function purgeFile($filename)
+    {
+        if (isset($this->_fileHandlers[$filename])) {
+            $this->_fileHandlers[$filename]->close();
+        }
+        unset($this->_fileHandlers[$filename]);
+    }
+
+
+    /**
+     * Returns true if a file with the given $filename exists.
+     *
+     * @param string $filename
+     * @return boolean
+     */
+    public function fileExists($filename)
+    {
+        return isset($this->_fileHandlers[$filename]) ||
+               file_exists($this->_dirPath . '/' . $filename);
+    }
+
+
+    /**
+     * Returns the length of a $filename in the directory.
+     *
+     * @param string $filename
+     * @return integer
+     */
+    public function fileLength($filename)
+    {
+        if (isset( $this->_fileHandlers[$filename] )) {
+            return $this->_fileHandlers[$filename]->size();
+        }
+        return filesize($this->_dirPath .'/'. $filename);
+    }
+
+
+    /**
+     * Returns the UNIX timestamp $filename was last modified.
+     *
+     * @param string $filename
+     * @return integer
+     */
+    public function fileModified($filename)
+    {
+        return filemtime($this->_dirPath .'/'. $filename);
+    }
+
+
+    /**
+     * Renames an existing file in the directory.
+     *
+     * @param string $from
+     * @param string $to
+     * @return void
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function renameFile($from, $to)
+    {
+        global $php_errormsg;
+
+        if (isset($this->_fileHandlers[$from])) {
+            $this->_fileHandlers[$from]->close();
+        }
+        unset($this->_fileHandlers[$from]);
+
+        if (isset($this->_fileHandlers[$to])) {
+            $this->_fileHandlers[$to]->close();
+        }
+        unset($this->_fileHandlers[$to]);
+
+        if (file_exists($this->_dirPath . '/' . $to)) {
+            if (!unlink($this->_dirPath . '/' . $to)) {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Delete operation failed');
+            }
+        }
+
+        $trackErrors = ini_get('track_errors');
+        ini_set('track_errors', '1');
+
+        $success = @rename($this->_dirPath . '/' . $from, $this->_dirPath . '/' . $to);
+        if (!$success) {
+            ini_set('track_errors', $trackErrors);
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception($php_errormsg);
+        }
+
+        ini_set('track_errors', $trackErrors);
+
+        return $success;
+    }
+
+
+    /**
+     * Sets the modified time of $filename to now.
+     *
+     * @param string $filename
+     * @return void
+     */
+    public function touchFile($filename)
+    {
+        return touch($this->_dirPath .'/'. $filename);
+    }
+
+
+    /**
+     * Returns a Zend_Search_Lucene_Storage_File object for a given $filename in the directory.
+     *
+     * If $shareHandler option is true, then file handler can be shared between File Object
+     * requests. It speed-ups performance, but makes problems with file position.
+     * Shared handler are good for short atomic requests.
+     * Non-shared handlers are useful for stream file reading (especial for compound files).
+     *
+     * @param string $filename
+     * @param boolean $shareHandler
+     * @return Zend_Search_Lucene_Storage_File
+     */
+    public function getFileObject($filename, $shareHandler = true)
+    {
+        $fullFilename = $this->_dirPath . '/' . $filename;
+
+        if (!$shareHandler) {
+            return new Zend_Search_Lucene_Storage_File_Filesystem($fullFilename);
+        }
+
+        if (isset( $this->_fileHandlers[$filename] )) {
+            $this->_fileHandlers[$filename]->seek(0);
+            return $this->_fileHandlers[$filename];
+        }
+
+        $this->_fileHandlers[$filename] = new Zend_Search_Lucene_Storage_File_Filesystem($fullFilename);
+        return $this->_fileHandlers[$filename];
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Storage/File.php b/inc/lib/Zend/Search/Lucene/Storage/File.php
new file mode 100644 (file)
index 0000000..8673bf0
--- /dev/null
@@ -0,0 +1,473 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: File.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+abstract class Zend_Search_Lucene_Storage_File
+{
+    /**
+     * Reads $length number of bytes at the current position in the
+     * file and advances the file pointer.
+     *
+     * @param integer $length
+     * @return string
+     */
+    abstract protected function _fread($length=1);
+
+
+    /**
+     * Sets the file position indicator and advances the file pointer.
+     * The new position, measured in bytes from the beginning of the file,
+     * is obtained by adding offset to the position specified by whence,
+     * whose values are defined as follows:
+     * SEEK_SET - Set position equal to offset bytes.
+     * SEEK_CUR - Set position to current location plus offset.
+     * SEEK_END - Set position to end-of-file plus offset. (To move to
+     * a position before the end-of-file, you need to pass a negative value
+     * in offset.)
+     * Upon success, returns 0; otherwise, returns -1
+     *
+     * @param integer $offset
+     * @param integer $whence
+     * @return integer
+     */
+    abstract public function seek($offset, $whence=SEEK_SET);
+
+    /**
+     * Get file position.
+     *
+     * @return integer
+     */
+    abstract public function tell();
+
+    /**
+     * Flush output.
+     *
+     * Returns true on success or false on failure.
+     *
+     * @return boolean
+     */
+    abstract public function flush();
+
+    /**
+     * Writes $length number of bytes (all, if $length===null) to the end
+     * of the file.
+     *
+     * @param string $data
+     * @param integer $length
+     */
+    abstract protected function _fwrite($data, $length=null);
+
+    /**
+     * Lock file
+     *
+     * Lock type may be a LOCK_SH (shared lock) or a LOCK_EX (exclusive lock)
+     *
+     * @param integer $lockType
+     * @return boolean
+     */
+    abstract public function lock($lockType, $nonBlockinLock = false);
+
+    /**
+     * Unlock file
+     */
+    abstract public function unlock();
+
+    /**
+     * Reads a byte from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return integer
+     */
+    public function readByte()
+    {
+        return ord($this->_fread(1));
+    }
+
+    /**
+     * Writes a byte to the end of the file.
+     *
+     * @param integer $byte
+     */
+    public function writeByte($byte)
+    {
+        return $this->_fwrite(chr($byte), 1);
+    }
+
+    /**
+     * Read num bytes from the current position in the file
+     * and advances the file pointer.
+     *
+     * @param integer $num
+     * @return string
+     */
+    public function readBytes($num)
+    {
+        return $this->_fread($num);
+    }
+
+    /**
+     * Writes num bytes of data (all, if $num===null) to the end
+     * of the string.
+     *
+     * @param string $data
+     * @param integer $num
+     */
+    public function writeBytes($data, $num=null)
+    {
+        $this->_fwrite($data, $num);
+    }
+
+
+    /**
+     * Reads an integer from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return integer
+     */
+    public function readInt()
+    {
+        $str = $this->_fread(4);
+
+        return  ord($str[0]) << 24 |
+                ord($str[1]) << 16 |
+                ord($str[2]) << 8  |
+                ord($str[3]);
+    }
+
+
+    /**
+     * Writes an integer to the end of file.
+     *
+     * @param integer $value
+     */
+    public function writeInt($value)
+    {
+        settype($value, 'integer');
+        $this->_fwrite( chr($value>>24 & 0xFF) .
+                        chr($value>>16 & 0xFF) .
+                        chr($value>>8  & 0xFF) .
+                        chr($value     & 0xFF),   4  );
+    }
+
+
+    /**
+     * Returns a long integer from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return integer|float
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function readLong()
+    {
+        /**
+         * Check, that we work in 64-bit mode.
+         * fseek() uses long for offset. Thus, largest index segment file size in 32bit mode is 2Gb
+         */
+        if (PHP_INT_SIZE > 4) {
+            $str = $this->_fread(8);
+
+            return  ord($str[0]) << 56  |
+                    ord($str[1]) << 48  |
+                    ord($str[2]) << 40  |
+                    ord($str[3]) << 32  |
+                    ord($str[4]) << 24  |
+                    ord($str[5]) << 16  |
+                    ord($str[6]) << 8   |
+                    ord($str[7]);
+        } else {
+            return $this->readLong32Bit();
+        }
+    }
+
+    /**
+     * Writes long integer to the end of file
+     *
+     * @param integer $value
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function writeLong($value)
+    {
+        /**
+         * Check, that we work in 64-bit mode.
+         * fseek() and ftell() use long for offset. Thus, largest index segment file size in 32bit mode is 2Gb
+         */
+        if (PHP_INT_SIZE > 4) {
+            settype($value, 'integer');
+            $this->_fwrite( chr($value>>56 & 0xFF) .
+                            chr($value>>48 & 0xFF) .
+                            chr($value>>40 & 0xFF) .
+                            chr($value>>32 & 0xFF) .
+                            chr($value>>24 & 0xFF) .
+                            chr($value>>16 & 0xFF) .
+                            chr($value>>8  & 0xFF) .
+                            chr($value     & 0xFF),   8  );
+        } else {
+            $this->writeLong32Bit($value);
+        }
+    }
+
+
+    /**
+     * Returns a long integer from the current position in the file,
+     * advances the file pointer and return it as float (for 32-bit platforms).
+     *
+     * @return integer|float
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function readLong32Bit()
+    {
+        $wordHigh = $this->readInt();
+        $wordLow  = $this->readInt();
+
+        if ($wordHigh & (int)0x80000000) {
+            // It's a negative value since the highest bit is set
+            if ($wordHigh == (int)0xFFFFFFFF  &&  ($wordLow & (int)0x80000000)) {
+                return $wordLow;
+            } else {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Long integers lower than -2147483648 (0x80000000) are not supported on 32-bit platforms.');
+            }
+
+        }
+
+        if ($wordLow < 0) {
+            // Value is large than 0x7FFF FFFF. Represent low word as float.
+            $wordLow &= 0x7FFFFFFF;
+            $wordLow += (float)0x80000000;
+        }
+
+        if ($wordHigh == 0) {
+            // Return value as integer if possible
+            return $wordLow;
+        }
+
+        return $wordHigh*(float)0x100000000/* 0x00000001 00000000 */ + $wordLow;
+    }
+
+
+    /**
+     * Writes long integer to the end of file (32-bit platforms implementation)
+     *
+     * @param integer|float $value
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function writeLong32Bit($value)
+    {
+        if ($value < (int)0x80000000) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Long integers lower than -2147483648 (0x80000000) are not supported on 32-bit platforms.');
+        }
+
+        if ($value < 0) {
+            $wordHigh = (int)0xFFFFFFFF;
+            $wordLow  = (int)$value;
+        } else {
+            $wordHigh = (int)($value/(float)0x100000000/* 0x00000001 00000000 */);
+            $wordLow  = $value - $wordHigh*(float)0x100000000/* 0x00000001 00000000 */;
+
+            if ($wordLow > 0x7FFFFFFF) {
+                // Highest bit of low word is set. Translate it to the corresponding negative integer value
+                $wordLow -= 0x80000000;
+                $wordLow |= 0x80000000;
+            }
+        }
+
+        $this->writeInt($wordHigh);
+        $this->writeInt($wordLow);
+    }
+
+
+    /**
+     * Returns a variable-length integer from the current
+     * position in the file and advances the file pointer.
+     *
+     * @return integer
+     */
+    public function readVInt()
+    {
+        $nextByte = ord($this->_fread(1));
+        $val = $nextByte & 0x7F;
+
+        for ($shift=7; ($nextByte & 0x80) != 0; $shift += 7) {
+            $nextByte = ord($this->_fread(1));
+            $val |= ($nextByte & 0x7F) << $shift;
+        }
+        return $val;
+    }
+
+    /**
+     * Writes a variable-length integer to the end of file.
+     *
+     * @param integer $value
+     */
+    public function writeVInt($value)
+    {
+        settype($value, 'integer');
+        while ($value > 0x7F) {
+            $this->_fwrite(chr( ($value & 0x7F)|0x80 ));
+            $value >>= 7;
+        }
+        $this->_fwrite(chr($value));
+    }
+
+
+    /**
+     * Reads a string from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return string
+     */
+    public function readString()
+    {
+        $strlen = $this->readVInt();
+        if ($strlen == 0) {
+            return '';
+        } else {
+            /**
+             * This implementation supports only Basic Multilingual Plane
+             * (BMP) characters (from 0x0000 to 0xFFFF) and doesn't support
+             * "supplementary characters" (characters whose code points are
+             * greater than 0xFFFF)
+             * Java 2 represents these characters as a pair of char (16-bit)
+             * values, the first from the high-surrogates range (0xD800-0xDBFF),
+             * the second from the low-surrogates range (0xDC00-0xDFFF). Then
+             * they are encoded as usual UTF-8 characters in six bytes.
+             * Standard UTF-8 representation uses four bytes for supplementary
+             * characters.
+             */
+
+            $str_val = $this->_fread($strlen);
+
+            for ($count = 0; $count < $strlen; $count++ ) {
+                if (( ord($str_val[$count]) & 0xC0 ) == 0xC0) {
+                    $addBytes = 1;
+                    if (ord($str_val[$count]) & 0x20 ) {
+                        $addBytes++;
+
+                        // Never used. Java2 doesn't encode strings in four bytes
+                        if (ord($str_val[$count]) & 0x10 ) {
+                            $addBytes++;
+                        }
+                    }
+                    $str_val .= $this->_fread($addBytes);
+                    $strlen += $addBytes;
+
+                    // Check for null character. Java2 encodes null character
+                    // in two bytes.
+                    if (ord($str_val[$count])   == 0xC0 &&
+                        ord($str_val[$count+1]) == 0x80   ) {
+                        $str_val[$count] = 0;
+                        $str_val = substr($str_val,0,$count+1)
+                                 . substr($str_val,$count+2);
+                    }
+                    $count += $addBytes;
+                }
+            }
+
+            return $str_val;
+        }
+    }
+
+    /**
+     * Writes a string to the end of file.
+     *
+     * @param string $str
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function writeString($str)
+    {
+        /**
+         * This implementation supports only Basic Multilingual Plane
+         * (BMP) characters (from 0x0000 to 0xFFFF) and doesn't support
+         * "supplementary characters" (characters whose code points are
+         * greater than 0xFFFF)
+         * Java 2 represents these characters as a pair of char (16-bit)
+         * values, the first from the high-surrogates range (0xD800-0xDBFF),
+         * the second from the low-surrogates range (0xDC00-0xDFFF). Then
+         * they are encoded as usual UTF-8 characters in six bytes.
+         * Standard UTF-8 representation uses four bytes for supplementary
+         * characters.
+         */
+
+        // convert input to a string before iterating string characters
+        settype($str, 'string');
+
+        $chars = $strlen = strlen($str);
+        $containNullChars = false;
+
+        for ($count = 0; $count < $strlen; $count++ ) {
+            /**
+             * String is already in Java 2 representation.
+             * We should only calculate actual string length and replace
+             * \x00 by \xC0\x80
+             */
+            if ((ord($str[$count]) & 0xC0) == 0xC0) {
+                $addBytes = 1;
+                if (ord($str[$count]) & 0x20 ) {
+                    $addBytes++;
+
+                    // Never used. Java2 doesn't encode strings in four bytes
+                    // and we dont't support non-BMP characters
+                    if (ord($str[$count]) & 0x10 ) {
+                        $addBytes++;
+                    }
+                }
+                $chars -= $addBytes;
+
+                if (ord($str[$count]) == 0 ) {
+                    $containNullChars = true;
+                }
+                $count += $addBytes;
+            }
+        }
+
+        if ($chars < 0) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Invalid UTF-8 string');
+        }
+
+        $this->writeVInt($chars);
+        if ($containNullChars) {
+            $this->_fwrite(str_replace($str, "\x00", "\xC0\x80"));
+        } else {
+            $this->_fwrite($str);
+        }
+    }
+
+
+    /**
+     * Reads binary data from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return string
+     */
+    public function readBinary()
+    {
+        return $this->_fread($this->readVInt());
+    }
+}
diff --git a/inc/lib/Zend/Search/Lucene/Storage/File/Filesystem.php b/inc/lib/Zend/Search/Lucene/Storage/File/Filesystem.php
new file mode 100644 (file)
index 0000000..69f5f97
--- /dev/null
@@ -0,0 +1,220 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Filesystem.php 16541 2009-07-07 06:59:03Z bkarwin $
+ */
+
+/** Zend_Search_Lucene_Storage_File */
+require_once 'Zend/Search/Lucene/Storage/File.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Storage_File_Filesystem extends Zend_Search_Lucene_Storage_File
+{
+    /**
+     * Resource of the open file
+     *
+     * @var resource
+     */
+    protected $_fileHandle;
+
+
+    /**
+     * Class constructor.  Open the file.
+     *
+     * @param string $filename
+     * @param string $mode
+     */
+    public function __construct($filename, $mode='r+b')
+    {
+        global $php_errormsg;
+
+        if (strpos($mode, 'w') === false  &&  !is_readable($filename)) {
+            // opening for reading non-readable file
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('File \'' . $filename . '\' is not readable.');
+        }
+
+        $trackErrors = ini_get('track_errors');
+        ini_set('track_errors', '1');
+
+        $this->_fileHandle = @fopen($filename, $mode);
+
+        if ($this->_fileHandle === false) {
+            ini_set('track_errors', $trackErrors);
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception($php_errormsg);
+        }
+
+        ini_set('track_errors', $trackErrors);
+    }
+
+    /**
+     * Sets the file position indicator and advances the file pointer.
+     * The new position, measured in bytes from the beginning of the file,
+     * is obtained by adding offset to the position specified by whence,
+     * whose values are defined as follows:
+     * SEEK_SET - Set position equal to offset bytes.
+     * SEEK_CUR - Set position to current location plus offset.
+     * SEEK_END - Set position to end-of-file plus offset. (To move to
+     * a position before the end-of-file, you need to pass a negative value
+     * in offset.)
+     * SEEK_CUR is the only supported offset type for compound files
+     *
+     * Upon success, returns 0; otherwise, returns -1
+     *
+     * @param integer $offset
+     * @param integer $whence
+     * @return integer
+     */
+    public function seek($offset, $whence=SEEK_SET)
+    {
+        return fseek($this->_fileHandle, $offset, $whence);
+    }
+
+
+    /**
+     * Get file position.
+     *
+     * @return integer
+     */
+    public function tell()
+    {
+        return ftell($this->_fileHandle);
+    }
+
+    /**
+     * Flush output.
+     *
+     * Returns true on success or false on failure.
+     *
+     * @return boolean
+     */
+    public function flush()
+    {
+        return fflush($this->_fileHandle);
+    }
+
+    /**
+     * Close File object
+     */
+    public function close()
+    {
+        if ($this->_fileHandle !== null ) {
+            @fclose($this->_fileHandle);
+            $this->_fileHandle = null;
+        }
+    }
+
+    /**
+     * Get the size of the already opened file
+     *
+     * @return integer
+     */
+    public function size()
+    {
+        $position = ftell($this->_fileHandle);
+        fseek($this->_fileHandle, 0, SEEK_END);
+        $size = ftell($this->_fileHandle);
+        fseek($this->_fileHandle,$position);
+
+        return $size;
+    }
+
+    /**
+     * Read a $length bytes from the file and advance the file pointer.
+     *
+     * @param integer $length
+     * @return string
+     */
+    protected function _fread($length=1)
+    {
+        if ($length == 0) {
+            return '';
+        }
+
+        if ($length < 1024) {
+            return fread($this->_fileHandle, $length);
+        }
+
+        $data = '';
+        while ( $length > 0 && ($nextBlock = fread($this->_fileHandle, $length)) != false ) {
+            $data .= $nextBlock;
+            $length -= strlen($nextBlock);
+        }
+        return $data;
+    }
+
+
+    /**
+     * Writes $length number of bytes (all, if $length===null) to the end
+     * of the file.
+     *
+     * @param string $data
+     * @param integer $length
+     */
+    protected function _fwrite($data, $length=null)
+    {
+        if ($length === null ) {
+            fwrite($this->_fileHandle, $data);
+        } else {
+            fwrite($this->_fileHandle, $data, $length);
+        }
+    }
+
+    /**
+     * Lock file
+     *
+     * Lock type may be a LOCK_SH (shared lock) or a LOCK_EX (exclusive lock)
+     *
+     * @param integer $lockType
+     * @param boolean $nonBlockingLock
+     * @return boolean
+     */
+    public function lock($lockType, $nonBlockingLock = false)
+    {
+        if ($nonBlockingLock) {
+            return flock($this->_fileHandle, $lockType | LOCK_NB);
+        } else {
+            return flock($this->_fileHandle, $lockType);
+        }
+    }
+
+    /**
+     * Unlock file
+     *
+     * Returns true on success
+     *
+     * @return boolean
+     */
+    public function unlock()
+    {
+        if ($this->_fileHandle !== null ) {
+            return flock($this->_fileHandle, LOCK_UN);
+        } else {
+            return true;
+        }
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/Storage/File/Memory.php b/inc/lib/Zend/Search/Lucene/Storage/File/Memory.php
new file mode 100644 (file)
index 0000000..2ec06ad
--- /dev/null
@@ -0,0 +1,601 @@
+<?php
+/**
+ * Zend Framework
+ *
+ * LICENSE
+ *
+ * This source file is subject to the new BSD license that is bundled
+ * with this package in the file LICENSE.txt.
+ * It is also available through the world-wide-web at this URL:
+ * http://framework.zend.com/license/new-bsd
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@zend.com so we can send you a copy immediately.
+ *
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ * @version    $Id: Memory.php 16971 2009-07-22 18:05:45Z mikaelkael $
+ */
+
+/** Zend_Search_Lucene_Storage_File */
+require_once 'Zend/Search/Lucene/Storage/File.php';
+
+/**
+ * @category   Zend
+ * @package    Zend_Search_Lucene
+ * @subpackage Storage
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License
+ */
+class Zend_Search_Lucene_Storage_File_Memory extends Zend_Search_Lucene_Storage_File
+{
+    /**
+     * FileData
+     *
+     * @var string
+     */
+    private $_data;
+
+    /**
+     * File Position
+     *
+     * @var integer
+     */
+    private $_position = 0;
+
+
+    /**
+     * Object constractor
+     *
+     * @param string $data
+     */
+    public function __construct($data)
+    {
+        $this->_data = $data;
+    }
+
+    /**
+     * Reads $length number of bytes at the current position in the
+     * file and advances the file pointer.
+     *
+     * @param integer $length
+     * @return string
+     */
+    protected function _fread($length = 1)
+    {
+        $returnValue = substr($this->_data, $this->_position, $length);
+        $this->_position += $length;
+        return $returnValue;
+    }
+
+
+    /**
+     * Sets the file position indicator and advances the file pointer.
+     * The new position, measured in bytes from the beginning of the file,
+     * is obtained by adding offset to the position specified by whence,
+     * whose values are defined as follows:
+     * SEEK_SET - Set position equal to offset bytes.
+     * SEEK_CUR - Set position to current location plus offset.
+     * SEEK_END - Set position to end-of-file plus offset. (To move to
+     * a position before the end-of-file, you need to pass a negative value
+     * in offset.)
+     * Upon success, returns 0; otherwise, returns -1
+     *
+     * @param integer $offset
+     * @param integer $whence
+     * @return integer
+     */
+    public function seek($offset, $whence=SEEK_SET)
+    {
+        switch ($whence) {
+            case SEEK_SET:
+                $this->_position = $offset;
+                break;
+
+            case SEEK_CUR:
+                $this->_position += $offset;
+                break;
+
+            case SEEK_END:
+                $this->_position = strlen($this->_data);
+                $this->_position += $offset;
+                break;
+
+            default:
+                break;
+        }
+    }
+
+    /**
+     * Get file position.
+     *
+     * @return integer
+     */
+    public function tell()
+    {
+        return $this->_position;
+    }
+
+    /**
+     * Flush output.
+     *
+     * Returns true on success or false on failure.
+     *
+     * @return boolean
+     */
+    public function flush()
+    {
+        // Do nothing
+
+        return true;
+    }
+
+    /**
+     * Writes $length number of bytes (all, if $length===null) to the end
+     * of the file.
+     *
+     * @param string $data
+     * @param integer $length
+     */
+    protected function _fwrite($data, $length=null)
+    {
+        // We do not need to check if file position points to the end of "file".
+        // Only append operation is supported now
+
+        if ($length !== null) {
+            $this->_data .= substr($data, 0, $length);
+        } else {
+            $this->_data .= $data;
+        }
+
+        $this->_position = strlen($this->_data);
+    }
+
+    /**
+     * Lock file
+     *
+     * Lock type may be a LOCK_SH (shared lock) or a LOCK_EX (exclusive lock)
+     *
+     * @param integer $lockType
+     * @return boolean
+     */
+    public function lock($lockType, $nonBlockinLock = false)
+    {
+        // Memory files can't be shared
+        // do nothing
+
+        return true;
+    }
+
+    /**
+     * Unlock file
+     */
+    public function unlock()
+    {
+        // Memory files can't be shared
+        // do nothing
+    }
+
+    /**
+     * Reads a byte from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return integer
+     */
+    public function readByte()
+    {
+        return ord($this->_data[$this->_position++]);
+    }
+
+    /**
+     * Writes a byte to the end of the file.
+     *
+     * @param integer $byte
+     */
+    public function writeByte($byte)
+    {
+        // We do not need to check if file position points to the end of "file".
+        // Only append operation is supported now
+
+        $this->_data .= chr($byte);
+        $this->_position = strlen($this->_data);
+
+        return 1;
+    }
+
+    /**
+     * Read num bytes from the current position in the file
+     * and advances the file pointer.
+     *
+     * @param integer $num
+     * @return string
+     */
+    public function readBytes($num)
+    {
+        $returnValue = substr($this->_data, $this->_position, $num);
+        $this->_position += $num;
+
+        return $returnValue;
+    }
+
+    /**
+     * Writes num bytes of data (all, if $num===null) to the end
+     * of the string.
+     *
+     * @param string $data
+     * @param integer $num
+     */
+    public function writeBytes($data, $num=null)
+    {
+        // We do not need to check if file position points to the end of "file".
+        // Only append operation is supported now
+
+        if ($num !== null) {
+            $this->_data .= substr($data, 0, $num);
+        } else {
+            $this->_data .= $data;
+        }
+
+        $this->_position = strlen($this->_data);
+    }
+
+
+    /**
+     * Reads an integer from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return integer
+     */
+    public function readInt()
+    {
+        $str = substr($this->_data, $this->_position, 4);
+        $this->_position += 4;
+
+        return  ord($str[0]) << 24 |
+                ord($str[1]) << 16 |
+                ord($str[2]) << 8  |
+                ord($str[3]);
+    }
+
+
+    /**
+     * Writes an integer to the end of file.
+     *
+     * @param integer $value
+     */
+    public function writeInt($value)
+    {
+        // We do not need to check if file position points to the end of "file".
+        // Only append operation is supported now
+
+        settype($value, 'integer');
+        $this->_data .= chr($value>>24 & 0xFF) .
+                        chr($value>>16 & 0xFF) .
+                        chr($value>>8  & 0xFF) .
+                        chr($value     & 0xFF);
+
+        $this->_position = strlen($this->_data);
+    }
+
+
+    /**
+     * Returns a long integer from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return integer
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function readLong()
+    {
+        /**
+         * Check, that we work in 64-bit mode.
+         * fseek() uses long for offset. Thus, largest index segment file size in 32bit mode is 2Gb
+         */
+        if (PHP_INT_SIZE > 4) {
+            $str = substr($this->_data, $this->_position, 8);
+            $this->_position += 8;
+
+            return  ord($str[0]) << 56  |
+                    ord($str[1]) << 48  |
+                    ord($str[2]) << 40  |
+                    ord($str[3]) << 32  |
+                    ord($str[4]) << 24  |
+                    ord($str[5]) << 16  |
+                    ord($str[6]) << 8   |
+                    ord($str[7]);
+        } else {
+            return $this->readLong32Bit();
+        }
+    }
+
+    /**
+     * Writes long integer to the end of file
+     *
+     * @param integer $value
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function writeLong($value)
+    {
+        // We do not need to check if file position points to the end of "file".
+        // Only append operation is supported now
+
+        /**
+         * Check, that we work in 64-bit mode.
+         * fseek() and ftell() use long for offset. Thus, largest index segment file size in 32bit mode is 2Gb
+         */
+        if (PHP_INT_SIZE > 4) {
+            settype($value, 'integer');
+            $this->_data .= chr($value>>56 & 0xFF) .
+                            chr($value>>48 & 0xFF) .
+                            chr($value>>40 & 0xFF) .
+                            chr($value>>32 & 0xFF) .
+                            chr($value>>24 & 0xFF) .
+                            chr($value>>16 & 0xFF) .
+                            chr($value>>8  & 0xFF) .
+                            chr($value     & 0xFF);
+        } else {
+            $this->writeLong32Bit($value);
+        }
+
+        $this->_position = strlen($this->_data);
+    }
+
+
+    /**
+     * Returns a long integer from the current position in the file,
+     * advances the file pointer and return it as float (for 32-bit platforms).
+     *
+     * @return integer|float
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function readLong32Bit()
+    {
+        $wordHigh = $this->readInt();
+        $wordLow  = $this->readInt();
+
+        if ($wordHigh & (int)0x80000000) {
+            // It's a negative value since the highest bit is set
+            if ($wordHigh == (int)0xFFFFFFFF  &&  ($wordLow & (int)0x80000000)) {
+                return $wordLow;
+            } else {
+                require_once 'Zend/Search/Lucene/Exception.php';
+                throw new Zend_Search_Lucene_Exception('Long integers lower than -2147483648 (0x80000000) are not supported on 32-bit platforms.');
+            }
+
+        }
+
+        if ($wordLow < 0) {
+            // Value is large than 0x7FFF FFFF. Represent low word as float.
+            $wordLow &= 0x7FFFFFFF;
+            $wordLow += (float)0x80000000;
+        }
+
+        if ($wordHigh == 0) {
+            // Return value as integer if possible
+            return $wordLow;
+        }
+
+        return $wordHigh*(float)0x100000000/* 0x00000001 00000000 */ + $wordLow;
+    }
+
+
+    /**
+     * Writes long integer to the end of file (32-bit platforms implementation)
+     *
+     * @param integer|float $value
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function writeLong32Bit($value)
+    {
+        if ($value < (int)0x80000000) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Long integers lower than -2147483648 (0x80000000) are not supported on 32-bit platforms.');
+        }
+
+        if ($value < 0) {
+            $wordHigh = (int)0xFFFFFFFF;
+            $wordLow  = (int)$value;
+        } else {
+            $wordHigh = (int)($value/(float)0x100000000/* 0x00000001 00000000 */);
+            $wordLow  = $value - $wordHigh*(float)0x100000000/* 0x00000001 00000000 */;
+
+            if ($wordLow > 0x7FFFFFFF) {
+                // Highest bit of low word is set. Translate it to the corresponding negative integer value
+                $wordLow -= 0x80000000;
+                $wordLow |= 0x80000000;
+            }
+        }
+
+        $this->writeInt($wordHigh);
+        $this->writeInt($wordLow);
+    }
+
+    /**
+     * Returns a variable-length integer from the current
+     * position in the file and advances the file pointer.
+     *
+     * @return integer
+     */
+    public function readVInt()
+    {
+        $nextByte = ord($this->_data[$this->_position++]);
+        $val = $nextByte & 0x7F;
+
+        for ($shift=7; ($nextByte & 0x80) != 0; $shift += 7) {
+            $nextByte = ord($this->_data[$this->_position++]);
+            $val |= ($nextByte & 0x7F) << $shift;
+        }
+        return $val;
+    }
+
+    /**
+     * Writes a variable-length integer to the end of file.
+     *
+     * @param integer $value
+     */
+    public function writeVInt($value)
+    {
+        // We do not need to check if file position points to the end of "file".
+        // Only append operation is supported now
+
+        settype($value, 'integer');
+        while ($value > 0x7F) {
+            $this->_data .= chr( ($value & 0x7F)|0x80 );
+            $value >>= 7;
+        }
+        $this->_data .= chr($value);
+
+        $this->_position = strlen($this->_data);
+    }
+
+
+    /**
+     * Reads a string from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return string
+     */
+    public function readString()
+    {
+        $strlen = $this->readVInt();
+        if ($strlen == 0) {
+            return '';
+        } else {
+            /**
+             * This implementation supports only Basic Multilingual Plane
+             * (BMP) characters (from 0x0000 to 0xFFFF) and doesn't support
+             * "supplementary characters" (characters whose code points are
+             * greater than 0xFFFF)
+             * Java 2 represents these characters as a pair of char (16-bit)
+             * values, the first from the high-surrogates range (0xD800-0xDBFF),
+             * the second from the low-surrogates range (0xDC00-0xDFFF). Then
+             * they are encoded as usual UTF-8 characters in six bytes.
+             * Standard UTF-8 representation uses four bytes for supplementary
+             * characters.
+             */
+
+            $str_val = substr($this->_data, $this->_position, $strlen);
+            $this->_position += $strlen;
+
+            for ($count = 0; $count < $strlen; $count++ ) {
+                if (( ord($str_val[$count]) & 0xC0 ) == 0xC0) {
+                    $addBytes = 1;
+                    if (ord($str_val[$count]) & 0x20 ) {
+                        $addBytes++;
+
+                        // Never used. Java2 doesn't encode strings in four bytes
+                        if (ord($str_val[$count]) & 0x10 ) {
+                            $addBytes++;
+                        }
+                    }
+                    $str_val .= substr($this->_data, $this->_position, $addBytes);
+                    $this->_position += $addBytes;
+                    $strlen          += $addBytes;
+
+                    // Check for null character. Java2 encodes null character
+                    // in two bytes.
+                    if (ord($str_val[$count])   == 0xC0 &&
+                        ord($str_val[$count+1]) == 0x80   ) {
+                        $str_val[$count] = 0;
+                        $str_val = substr($str_val,0,$count+1)
+                                 . substr($str_val,$count+2);
+                    }
+                    $count += $addBytes;
+                }
+            }
+
+            return $str_val;
+        }
+    }
+
+    /**
+     * Writes a string to the end of file.
+     *
+     * @param string $str
+     * @throws Zend_Search_Lucene_Exception
+     */
+    public function writeString($str)
+    {
+        /**
+         * This implementation supports only Basic Multilingual Plane
+         * (BMP) characters (from 0x0000 to 0xFFFF) and doesn't support
+         * "supplementary characters" (characters whose code points are
+         * greater than 0xFFFF)
+         * Java 2 represents these characters as a pair of char (16-bit)
+         * values, the first from the high-surrogates range (0xD800-0xDBFF),
+         * the second from the low-surrogates range (0xDC00-0xDFFF). Then
+         * they are encoded as usual UTF-8 characters in six bytes.
+         * Standard UTF-8 representation uses four bytes for supplementary
+         * characters.
+         */
+
+        // We do not need to check if file position points to the end of "file".
+        // Only append operation is supported now
+
+        // convert input to a string before iterating string characters
+        settype($str, 'string');
+
+        $chars = $strlen = strlen($str);
+        $containNullChars = false;
+
+        for ($count = 0; $count < $strlen; $count++ ) {
+            /**
+             * String is already in Java 2 representation.
+             * We should only calculate actual string length and replace
+             * \x00 by \xC0\x80
+             */
+            if ((ord($str[$count]) & 0xC0) == 0xC0) {
+                $addBytes = 1;
+                if (ord($str[$count]) & 0x20 ) {
+                    $addBytes++;
+
+                    // Never used. Java2 doesn't encode strings in four bytes
+                    // and we dont't support non-BMP characters
+                    if (ord($str[$count]) & 0x10 ) {
+                        $addBytes++;
+                    }
+                }
+                $chars -= $addBytes;
+
+                if (ord($str[$count]) == 0 ) {
+                    $containNullChars = true;
+                }
+                $count += $addBytes;
+            }
+        }
+
+        if ($chars < 0) {
+            require_once 'Zend/Search/Lucene/Exception.php';
+            throw new Zend_Search_Lucene_Exception('Invalid UTF-8 string');
+        }
+
+        $this->writeVInt($chars);
+        if ($containNullChars) {
+            $this->_data .= str_replace($str, "\x00", "\xC0\x80");
+
+        } else {
+            $this->_data .= $str;
+        }
+
+        $this->_position = strlen($this->_data);
+    }
+
+
+    /**
+     * Reads binary data from the current position in the file
+     * and advances the file pointer.
+     *
+     * @return string
+     */
+    public function readBinary()
+    {
+        $length = $this->readVInt();
+        $returnValue = substr($this->_data, $this->_position, $length);
+        $this->_position += $length;
+        return $returnValue;
+    }
+}
+
diff --git a/inc/lib/Zend/Search/Lucene/TermStreamsPriorityQueue.php b/inc/lib/Zend/Search/Lucene/TermStreamsPriorityQueue.php
new file mode 100644 (file)
index 0000000..f8c4527
--- /dev/null
@@ -0,0 +1,176 @@
+<?php\r
+/**\r
+ * Zend Framework\r
+ *\r
+ * LICENSE\r
+ *\r
+ * This source file is subject to the new BSD license that is bundled\r
+ * with this package in the file LICENSE.txt.\r
+ * It is also available through the world-wide-web at this URL:\r
+ * http://framework.zend.com/license/new-bsd\r
+ * If you did not receive a copy of the license and are unable to\r
+ * obtain it through the world-wide-web, please send an email\r
+ * to license@zend.com so we can send you a copy immediately.\r
+ *\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Index\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ * @version    $Id: TermStreamsPriorityQueue.php 16971 2009-07-22 18:05:45Z mikaelkael $\r
+ */\r
+\r
+/** Zend_Search_Lucene_Index_TermsStream_Interface */\r
+require_once 'Zend/Search/Lucene/Index/TermsStream/Interface.php';\r
+\r
+/** Zend_Search_Lucene_Index_TermsPriorityQueue */\r
+require_once 'Zend/Search/Lucene/Index/TermsPriorityQueue.php';\r
+\r
+\r
+/**\r
+ * @category   Zend\r
+ * @package    Zend_Search_Lucene\r
+ * @subpackage Index\r
+ * @copyright  Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)\r
+ * @license    http://framework.zend.com/license/new-bsd     New BSD License\r
+ */\r
+class Zend_Search_Lucene_TermStreamsPriorityQueue implements Zend_Search_Lucene_Index_TermsStream_Interface\r
+{\r
+       /**\r
+        * Array of term streams (Zend_Search_Lucene_Index_TermsStream_Interface objects)\r
+        *\r
+        * @var array\r
+        */\r
+       protected $_termStreams;\r
+\r
+       /**\r
+     * Terms stream queue\r
+     *\r
+     * @var Zend_Search_Lucene_Index_TermsPriorityQueue\r
+     */\r
+    protected $_termsStreamQueue = null;\r
+\r
+    /**\r
+     * Last Term in a terms stream\r
+     *\r
+     * @var Zend_Search_Lucene_Index_Term\r
+     */\r
+    protected $_lastTerm = null;\r
+\r
+\r
+    /**\r
+     * Object constructor\r
+     *\r
+     * @param array $termStreams  array of term streams (Zend_Search_Lucene_Index_TermsStream_Interface objects)\r
+     */\r
+       public function __construct(array $termStreams)\r
+       {\r
+               $this->_termStreams = $termStreams;\r
+\r
+               $this->resetTermsStream();\r
+       }\r
+\r
+       /**\r
+     * Reset terms stream.\r
+     */\r
+    public function resetTermsStream()\r
+    {\r
+        $this->_termsStreamQueue = new Zend_Search_Lucene_Index_TermsPriorityQueue();\r
+\r
+        foreach ($this->_termStreams as $termStream) {\r
+            $termStream->resetTermsStream();\r
+\r
+            // Skip "empty" containers\r
+            if ($termStream->currentTerm() !== null) {\r
+                $this->_termsStreamQueue->put($termStream);\r
+            }\r
+        }\r
+\r
+        $this->nextTerm();\r
+    }\r
+\r
+    /**\r
+     * Skip terms stream up to specified term preffix.\r
+     *\r
+     * Prefix contains fully specified field info and portion of searched term\r
+     *\r
+     * @param Zend_Search_Lucene_Index_Term $prefix\r
+     */\r
+    public function skipTo(Zend_Search_Lucene_Index_Term $prefix)\r
+    {\r
+        $termStreams = array();\r
+\r
+        while (($termStream = $this->_termsStreamQueue->pop()) !== null) {\r
+            $termStreams[] = $termStream;\r
+        }\r
+\r
+        foreach ($termStreams as $termStream) {\r
+            $termStream->skipTo($prefix);\r
+\r
+            if ($termStream->currentTerm() !== null) {\r
+                $this->_termsStreamQueue->put($termStream);\r
+            }\r
+        }\r
+\r
+        $this->nextTerm();\r
+    }\r
+\r
+    /**\r
+     * Scans term streams and returns next term\r
+     *\r
+     * @return Zend_Search_Lucene_Index_Term|null\r
+     */\r
+    public function nextTerm()\r
+    {\r
+        while (($termStream = $this->_termsStreamQueue->pop()) !== null) {\r
+            if ($this->_termsStreamQueue->top() === null ||\r
+                $this->_termsStreamQueue->top()->currentTerm()->key() !=\r
+                            $termStream->currentTerm()->key()) {\r
+                // We got new term\r
+                $this->_lastTerm = $termStream->currentTerm();\r
+\r
+                if ($termStream->nextTerm() !== null) {\r
+                    // Put segment back into the priority queue\r
+                    $this->_termsStreamQueue->put($termStream);\r
+                }\r
+\r
+                return $this->_lastTerm;\r
+            }\r
+\r
+            if ($termStream->nextTerm() !== null) {\r
+                // Put segment back into the priority queue\r
+                $this->_termsStreamQueue->put($termStream);\r
+            }\r
+        }\r
+\r
+        // End of stream\r
+        $this->_lastTerm = null;\r
+\r
+        return null;\r
+    }\r
+\r
+    /**\r
+     * Returns term in current position\r
+     *\r
+     * @return Zend_Search_Lucene_Index_Term|null\r
+     */\r
+    public function currentTerm()\r
+    {\r
+        return $this->_lastTerm;\r
+    }\r
+\r
+    /**\r
+     * Close terms stream\r
+     *\r
+     * Should be used for resources clean up if stream is not read up to the end\r
+     */\r
+    public function closeTermsStream()\r
+    {\r
+        while (($termStream = $this->_termsStreamQueue->pop()) !== null) {\r
+            $termStream->closeTermsStream();\r
+        }\r
+\r
+        $this->_termsStreamQueue = null;\r
+        $this->_lastTerm         = null;\r
+    }\r
+}\r
diff --git a/inc/milestone.php b/inc/milestone.php
new file mode 100644 (file)
index 0000000..6a2b9a8
--- /dev/null
@@ -0,0 +1,387 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackMilestone {
+  public $mid = null;
+  public $pmid = null;
+  public $name = null;
+  public $description = null;
+  public $duedate = null;
+  public $startdate = null;
+  public $deleted = null;
+  public $completed = null;
+  public $created = null;
+
+  static function loadByName($name)
+  {
+    foreach (MTrackDB::q('select mid from milestones where lower(name) = lower(?)', $name)
+        ->fetchAll() as $row) {
+      return new self($row[0]);
+    }
+    return null;
+  }
+
+  static function loadByID($id)
+  {
+    foreach (MTrackDB::q('select mid from milestones where mid = ?', $id)
+        ->fetchAll() as $row) {
+      return new self($row[0]);
+    }
+    return null;
+  }
+
+  static function enumMilestones($all = false)
+  {
+    if ($all) {
+      $q = MTrackDB::q('select mid, name from milestones where deleted != 1');
+    } else {
+      $q = MTrackDB::q('select mid, name from milestones where completed is null and deleted != 1');
+    }
+    $res = array();
+    foreach ($q->fetchAll(PDO::FETCH_NUM) as $row) {
+      $res[$row[0]] = $row[1];
+    }
+    return $res;
+  }
+
+  function __construct($id = null)
+  {
+    if ($id !== null) {
+      $this->mid = $id;
+
+      list($row) = MTrackDB::q('select * from milestones where mid = ?', $id)
+        ->fetchAll(PDO::FETCH_ASSOC);
+      foreach ($row as $k => $v) {
+        $this->$k = $v;
+      }
+    }
+    $this->deleted = false;
+  }
+
+  function save(MTrackChangeset $CS)
+  {
+    $this->updated = $CS->cid;
+
+    if ($this->mid === null) {
+      $this->created = $CS->cid;
+
+      MTrackDB::q('insert into milestones
+          (name, description, startdate, duedate, completed, created,
+            pmid, updated, deleted)
+          values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+        $this->name,
+        $this->description,
+        $this->startdate,
+        $this->duedate,
+        $this->completed,
+        $this->created,
+        $this->pmid,
+        $this->updated,
+        (int)$this->deleted);
+
+      $this->mid = MTrackDB::lastInsertId('milestones', 'mid');
+    } else {
+      list($old) = MTrackDB::q(
+          'select * from milestones where mid = ?', $this->mid)->fetchAll();
+      foreach ($old as $k => $v) {
+        if ($k == 'mid' || $k == 'created' || $k == 'updated') {
+          continue;
+        }
+        $CS->add("milestone:$this->mid:$k", $v, $this->$k);
+      }
+      MTrackDB::q('update milestones set name = ?,
+          description = ?, startdate = ?, duedate = ?, completed = ?,
+          updated = ?, deleted = ?, pmid = ?
+          WHERE mid = ?',
+        $this->name,
+        $this->description,
+        $this->startdate,
+        $this->duedate,
+        $this->completed,
+        $this->updated,
+        (int)$this->deleted,
+        $this->pmid,
+        $this->mid);
+    }
+  }
+
+  static function macro_BurnDown() {
+    global $ABSWEB;
+
+    $args = func_get_args();
+
+    if (!count($args) || (count($args) == 1 && $args[0] == '')) {
+      # Special case for allowing burndown to NOP in the milestone summary
+      return '';
+    }
+
+    $params = array(
+      'width' => '75%',
+      'height' => '250px',
+    );
+
+    foreach ($args as $arg) {
+      list($name, $value) = explode('=', $arg, 2);
+      $params[$name] = $value;
+    }
+
+    $m = MTrackMilestone::loadByName($params['milestone']);
+    if (!$m) {
+      return "BurnDown: milestone $params[milestone] is invalid<br>\n";
+    }
+    if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) {
+      return "Not authorized to view milestone $name<br>\n";
+    }
+    /* step 1: find all changes on this milestone and its children */
+    $effort = MTrackDB::q("
+      select expended, remaining, changedate
+      from
+        ticket_milestones tm
+      left join
+        effort e on (tm.tid = e.tid)
+      left join
+        changes c on (e.cid = c.cid)
+      where (mid = ? 
+        or (mid in (select mid from milestones where pmid = ?))
+      )
+         and c.changedate is not null
+      order by c.changedate",
+      $m->mid, $m->mid)->fetchAll(PDO::FETCH_NUM);
+
+    /* estimated hours by day */
+    $estimate_by_day = array();
+    /* accumulated work spent by day */
+    $accum_spent_by_day = array();
+    /* accumulated remaining hours by day */
+    $accum_remain_by_day = array();
+
+    $current_estimate = null;
+    $min_day = null;
+    $max_value = 0;
+    $total_exp = 0;
+
+    foreach ($effort as $info) {
+      list($exp, $rem, $date) = $info;
+      list($day, $rest) = explode('T', $date, 2);
+
+      /* previous day estimate carries over to today */
+      if (!isset($estimate_by_day[$day])) {
+        $estimate_by_day[$day] = $current_estimate;
+      }
+
+      /* previous accumulation carries over */
+      if (!isset($accum_spent_by_day[$day])) {
+        $accum_spent_by_day[$day] = $total_exp;
+      }
+
+      /* revise the estimate for today; also applies
+       * to the number we carry over to tomorrow */
+      if ($rem !== null) {
+        $estimate_by_day[$day] += $rem;
+        $current_estimate = $estimate_by_day[$day];
+      }
+
+      if ($exp !== null) {
+        if ($exp != 0 && $min_day === null) {
+          $min_day = strtotime($date);
+        }
+        $accum_spent_by_day[$day] += $exp;
+        $total_exp += $exp;
+      }
+      $accum_remain_by_day[$day] = $current_estimate - $total_exp;
+      $max_value = max($max_value, $current_estimate);
+    }
+
+    $init_estimate = 0;
+    foreach ($estimate_by_day as $v) {
+      if ($v) {
+        $init_estimate = $v;
+        break;
+      }
+    }
+
+    /* limit the view to the past 3 weeks */
+    $earliest = strtotime('-3 week');
+    if ($min_day < $earliest) {
+//      $min_day = $earliest;
+    }
+    $min_day *= 1000;
+
+    if ($m->duedate) {
+      $maxday = strtotime($m->duedate);
+    } else {
+      $maxday = time();
+    }
+    $maxday = strtotime('1 week', $maxday) * 1000;
+
+    /* step 3: compute the day by day remaining value,
+     * and produce data series for remaining and expended time */
+
+    $js_remain = array();
+    $js_estimate = array();
+    $trend = array();
+    foreach ($accum_remain_by_day as $day => $remaining) {
+
+      /* compute javascript timestamp */
+      list($year, $month, $dayno) = explode('-', $day);
+      $ts = gmmktime(0, 0, 0, $month, $dayno, $year) * 1000;
+
+      $js_remain[] = "[$ts, $remaining]";
+      $est = (int)$estimate_by_day[$day];
+      $js_estimate[] = "[$ts, $est]";
+      $trend[$ts] = $remaining;
+    }
+
+    $js_remain = join(',', $js_remain);
+    $js_estimate = join(',', $js_estimate);
+
+    $flot = "bd_graph_" . sha1(join(':', $args) . time());
+
+    $max_value *= 1.2;
+
+    $height = (int)$params['height'];
+
+    $html = "
+<div id='$flot' class='flotgraph'
+  style='width: $params[width]; height: $params[height];'></div>
+<script id='source_$flot' language='javascript' type='text/javascript'>
+\$(function () {
+  var p = \$('#$flot');
+  // Not sure what's up here, but somehow the height for the element
+  // shows up as 0 in safari, despite the style setting above... so let's
+  // just force the height here.
+  if (p.height() == 0) {
+    p.height($height);
+  }
+  \$.plot(p, [
+    { label: \"estimated\", data: [$js_estimate], yaxis: 1 },
+    { label: \"remaining\", data: [$js_remain] }
+    ], {
+     xaxis: {
+       mode: \"time\",
+       timeformat: '%b %d',
+       min: $min_day,
+       max: $maxday
+     },
+     yaxis: {
+      max: $max_value
+     },
+     legend: {
+      position: 'sw'
+     },
+     grid: {
+      hoverable: true
+     }
+    }
+  );
+});
+</script>
+";
+
+    $delta = $init_estimate - $total_exp;
+
+    return
+      "<div class='burndown'>Initial estimate: $init_estimate, Work expended: $total_exp<br>\n"
+      . $html . "</div>";
+  }
+
+  static function macro_MilestoneSummary($name) {
+    global $ABSWEB;
+
+    $m = self::loadByName($name);
+    if (!$m) {
+      return "milestone: " . htmlentities($name) . " not found<br>\n";
+    }
+
+    if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) {
+      return "Not authorized to view milestone $name<br>\n";
+    }
+   
+    $completed = mtrack_date($m->completed);
+    $description = $m->description;
+    if (strpos($description, "[[BurnDown(") === false) {
+      $description = "[[BurnDown(milestone=$name,width=50%,height=150)]]\n" .
+        $description;
+    }
+    $desc = MTrackWiki::format_to_html($description);
+    $pname = $name;
+    if ($m->completed !== NULL) {
+      $pname = "<del>$name</del>";
+      $due = "Completed";
+    } elseif ($m->duedate) {
+      $due = "Due " . mtrack_date($m->duedate);
+    } else {
+      $due = null;
+    }
+
+    $watch = MTrackWatch::getWatchUI('milestone', $m->mid);
+
+    $html = <<<HTML
+<div class="milestone">
+<h2><a href="{$ABSWEB}milestone.php/$name">$pname</a></h2>
+$watch
+<div class="due">$due</div>
+$desc<br/>
+HTML;
+
+    $estimated = 0;
+    $remaining = 0;
+    $open = 0;
+    $total = 0;
+
+    foreach (MTrackDB::q('select status, estimated, estimated - spent as remaining from ticket_milestones tm left join tickets t on (tm.tid = t.tid) where mid = ?',
+        $m->mid)->fetchAll(PDO::FETCH_ASSOC) as $row) {
+      $total++;
+      if ($row['status'] != 'closed') {
+        $open++;
+      }
+      $estimated += $row['estimated'];
+      $remaining += $row['remaining'];
+    }
+
+    $closed = $total - $open;
+    if ($total) {
+      $apct = (int)($open / $total * 100);
+    } else {
+      $apct = 0;
+    }
+    $cpct = 100 - $apct;
+    $html .= <<<HTML
+<table class='progress'>
+<tr>
+  <td class='closed' style='width:$cpct%;'><a href='#'></a></td>
+HTML;
+
+    if ($open) {
+      $html .= <<<HTML
+  <td class='open' style='width:$apct%;'><a href='#'> </a></td>
+HTML;
+    }
+
+    $ms = urlencode($name);
+
+    $html .= <<<HTML
+</tr>
+</table>
+<a href='{$ABSWEB}query.php?milestone=$ms&status!=closed'>$open open</a>,
+<a href='{$ABSWEB}query.php?milestone=$ms&status=closed'>$closed closed</a>,
+<a href='{$ABSWEB}query.php?milestone=$ms'>$total total</a> ($cpct % complete)
+</div>
+HTML;
+    return $html;
+  }
+}
+
+MTrackWiki::register_macro('MilestoneSummary',
+  array('MTrackMilestone', 'macro_MilestoneSummary'));
+
+MTrackWiki::register_macro('BurnDown',
+  array('MTrackMilestone', 'macro_BurnDown'));
+
+MTrackACL::registerAncestry('milestone', 'Roadmap');
+MTrackWatch::registerEventTypes('milestone', array(
+  'ticket' => 'Tickets',
+  'changeset' => 'Code changes'
+));
+
diff --git a/inc/report.php b/inc/report.php
new file mode 100644 (file)
index 0000000..0cd235a
--- /dev/null
@@ -0,0 +1,626 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackReport {
+  public $rid = null;
+  public $summary = null;
+  public $description = null;
+  public $query = null;
+  public $changed = null;
+
+  static function loadByID($id) {
+    return new MTrackReport($id);
+  }
+
+  static function loadBySummary($summary) {
+    list($row) = MTrackDB::q('select rid from reports where summary = ?',
+      $summary)->fetchAll();
+    if (isset($row[0])) {
+      return new MTrackReport($row[0]);
+    }
+    return null;
+  }
+
+  function __construct($id = null) {
+    $this->rid = $id;
+    if ($this->rid) {
+      $q = MTrackDB::q('select * from reports where rid = ?', $this->rid);
+      foreach ($q->fetchAll() as $row) {
+        $this->summary = $row['summary'];
+        $this->description = $row['description'];
+        $this->query = $row['query'];
+        $this->changed = (int)$row['changed'];
+        return;
+      }
+      throw new Exception("report $id not found");
+    }
+  }
+
+  function save(MTrackChangeset $changeset) {
+    if ($this->rid) {
+      
+      /* figure what we actually changed */
+      $q = MTrackDB::q('select * from reports where rid = ?', $this->rid);
+      list($row) = $q->fetchAll();
+    
+      $changeset->add("report:" . $this->rid . ":summary",
+        $row['summary'], $this->summary);
+      $changeset->add("report:" . $this->rid . ":description",
+        $row['description'], $this->description);
+      $changeset->add("report:" . $this->rid . ":query",
+        $row['query'], $this->query);
+
+      $q = MTrackDB::q('update reports set summary = ?, description = ?, query = ?, changed = ? where rid = ?',
+            $this->summary, $this->description, $this->query,
+            $changeset->cid, $this->rid);
+    } else {
+      $q = MTrackDB::q('insert into reports (summary, description, query, changed) values (?, ?, ?, ?)',
+            $this->summary, $this->description, $this->query,
+            $changeset->cid);
+      $this->rid = MTrackDB::lastInsertId('reports', 'rid');
+      $changeset->add("report:" . $this->rid . ":summary",
+        null, $this->summary);
+      $changeset->add("report:" . $this->rid . ":description",
+        null, $this->description);
+      $changeset->add("report:" . $this->rid . ":query",
+        null, $this->query);
+
+    }
+  }
+  static function renderReport($repstring, $passed_params = null,
+      $format = 'html') {
+    global $ABSWEB;
+    static $jquery_init = false;
+
+    $db = MTrackDB::get();
+
+    /* process the report string; any $PARAM in there is recognized
+     * as a parameter and the query munged accordingly to pass in the data */
+
+    $params = array();
+    try {
+      $n = preg_match_all("/\\$([A-Z]+)/m", $repstring, $matches);
+      for ($i = 1; $i <= $n; $i++) {
+        /* default the parameter to no value */
+        $params[$matches[$i][0]] = '';
+        /* replace with query placeholder */
+        $repstring = str_replace('$' . $matches[$i][0], ':' . $matches[$i][0],
+          $repstring);
+      }
+
+      /* now to summon parameters */
+      if (isset($params['USER'])) {
+        $params['USER'] = MTrackAuth::whoami();
+      }
+      foreach ($params as $p => $v) {
+        if (isset($_GET[$p])) {
+          $params[$p] = $_GET[$p];
+        }
+      }
+      if (is_array($passed_params)) {
+        foreach ($params as $p => $v) {
+          if (isset($passed_params[$p])) {
+            $params[$p] = $passed_params[$p];
+          }
+        }
+      }
+
+      $q = $db->prepare($repstring);
+      $q->execute($params);
+
+      $results = $q->fetchAll(PDO::FETCH_ASSOC);
+    } catch (Exception $e) {
+      return "<div class='error'>" . $e->getMessage() . "<br>" . 
+        htmlentities($repstring, ENT_QUOTES, 'utf-8') . "</div>";
+    }
+
+    $out = '';
+
+    if (count($results) == 0) {
+      return "No records matched";
+    }
+
+    /* figure out the table headings */
+    $captions = array();
+    $span = array();
+    $rules = array();
+    foreach ($results[0] as $name => $value) {
+      if (preg_match("/^__.*__$/", $name)) {
+        if ($format == 'html') {
+          /* special meaning, not a column */
+          continue;
+        }
+      }
+      $captions[$name] = preg_replace("/^_(.*)_$/", "\\1", $name);
+    }
+    /* for spanning purposes, calculate the longest row */
+    $max_width = 0;
+    $width = 0;
+    foreach ($captions as $name => $caption) {
+      if ($name[0] == '_' && substr($name, -1) == '_') {
+        $width = 1;
+      } else {
+        $width++;
+      }
+      if ($width > $max_width) {
+        $max_width = $width;
+      }
+      if (substr($name, -1) == '_') {
+        $width = 1;
+      }
+    }
+
+    $group = null;
+    foreach ($results as $nrow => $row) {
+      $starting_new_group = false;
+
+      if ($nrow == 0) {
+        $starting_new_group = true;
+      } else if ($format == 'html' &&
+          (isset($row['__group__']) && $group !== $row['__group__'])) {
+        $starting_new_group = true;
+      }
+
+      if ($starting_new_group) {
+        /* starting a new group */
+        if ($nrow) {
+          /* close the old one */
+          if ($format == 'html') {
+            $out .= "</tbody></table>\n";
+          }
+        }
+        if ($format == 'html' && isset($row['__group__'])) {
+          $out .= "<h2 class='reportgroup'>" .
+            htmlentities($row['__group__'], ENT_COMPAT, 'utf-8') .
+            "</h2>\n";
+          $group = $row['__group__'];
+        }
+
+        if ($format == 'html') {
+          $out .= "<table class='report'><thead><tr>";
+        }
+
+        foreach ($captions as $name => $caption) {
+
+          /* figure out sort info for javascript bits */
+          $sort = null;
+          switch (strtolower($caption)) {
+            case 'priority':
+            case 'ticket':
+            case 'severity':
+              $sort = strtolower($caption);
+              break;
+            case 'created':
+            case 'modified':
+            case 'date':
+            case 'due':
+              $sort = 'mtrackdate';
+              break;
+            case 'remaining':
+              $sort = 'digit';
+              break;
+            case 'updated':
+            case 'time':
+            case 'content':
+            case 'summary':
+            default:
+              break;
+          }
+
+          $caption = ucfirst($caption);
+          if ($name[0] == '_' && substr($name,-1) == '_') {
+            if ($format == 'html') {
+              $out .= "</tr><tr><th colspan='$max_width'>$caption</th></tr><tr>";
+            } else if ($format == 'tab') {
+              $out .= "$caption\t";
+            }
+          } elseif ($name[0] == '_') {
+            continue;
+          } else {
+            if ($format == 'html') {
+              $out .= "<th";
+              if ($sort !== null) {
+                $out .= " class=\"{sorter: '$sort'}\"";
+              }
+              $out .= ">$caption</th>";
+              if (substr($name, -1) == '_') {
+                $out .= "</tr><tr>";
+              }
+            } else if ($format == 'tab') {
+              $out .= "$caption\t";
+            }
+          }
+        }
+        if ($format == 'html') {
+          $out .= "</tr></thead><tbody>\n";
+        } else if ($format == 'tab') {
+          $out .= "\n";
+        }
+      }
+
+      /* and now the column data itself */
+      if (isset($row['__style__'])) {
+        $style = " style=\"$row[__style__]\"";
+      } else {
+        $style = "";
+      }
+      $class = $nrow % 2 ? "even" : "odd";
+      if (isset($row['__color__'])) {
+        $class .= " color$row[__color__]";
+      }
+      if (isset($row['__status__'])) {
+        $class .= " status$row[__status__]";
+      }
+
+      if ($format == 'html') {
+        $begin_row = "<tr class=\"$class\"$style>";
+        $out .= $begin_row;
+      }
+      $href = null;
+
+      /* determine if we should link to something for this row */
+      if (isset($row['ticket'])) {
+        $href = $ABSWEB . "ticket.php/$row[ticket]";
+      }
+
+      foreach ($captions as $name => $caption) {
+        $v = $row[$name];
+
+        /* apply special formatting rules */
+        if ($format == 'html') {
+          switch (strtolower($caption)) {
+            case 'created':
+            case 'modified':
+            case 'date':
+            case 'due':
+            case 'updated':
+            case 'time':
+              if ($v !== null) {
+                $v = mtrack_date($v);
+              }
+              break;
+            case 'content':
+              $v = MTrackWiki::format_to_html($v);
+              break;
+            case 'owner':
+              $v = mtrack_username($v, array('no_image' => true));
+              break;
+            case 'docid':
+            case 'ticket':
+              $v = mtrack_ticket($row);
+              break;
+            case 'summary':
+              if ($href) {
+                $v = htmlentities($v, ENT_QUOTES, 'utf-8');
+                $v = "<a href=\"$href\">$v</a>";
+              } else {
+                $v = htmlentities($v, ENT_QUOTES, 'utf-8');
+              }
+              break;
+            case 'milestone':
+              $oldv = $v;
+              $v = '';
+              foreach (preg_split("/\s*,\s*/", $oldv) as $m) {
+                if (!strlen($m)) continue;
+                $v .= "<span class='milestone'>" .
+                      "<a href=\"{$ABSWEB}milestone.php/" .
+                      urlencode($m) . "\">" .
+                      htmlentities($m, ENT_QUOTES, 'utf-8') .
+                      "</a></span> ";
+              }
+              break;
+            case 'keyword':
+              $oldv = $v;
+              $v = '';
+              foreach (preg_split("/\s*,\s*/", $oldv) as $m) {
+                if (!strlen($m)) continue;
+                $v .= mtrack_keyword($m) . ' ';
+              }
+              break;
+            default:
+              $v = htmlentities($v, ENT_QUOTES, 'utf-8');
+          }
+        } else if ($format == 'tab') {
+          $v = trim(preg_replace("/[\t\n\r]+/sm", " ", $v));
+        }
+
+        if ($name[0] == '_' && substr($name, -1) == '_') {
+          if ($format == 'html') {
+            $out .= "</tr>$begin_row<td class='$caption' colspan='$max_width'>$v</td></tr>$begin_row";
+          } else if ($format == 'tab') {
+            $out .= "$v\t";
+          }
+        } elseif ($name[0] == '_') {
+          if ($format == 'tab') {
+            $out .= "$v\t";
+          } else {
+            continue;
+          }
+        } else {
+          if ($format == 'html') {
+            $out .= "<td class='$caption'>$v</td>";
+            if (substr($name, -1) == '_') {
+              $out .= "</tr>$begin_row";
+            }
+          } else if ($format == 'tab') {
+            $out .= "$v\t";
+          }
+        }
+      }
+      if ($format == 'html') {
+        $out .= "</tr>\n";
+      } else if ($format == 'tab') {
+        $out .= "\n";
+      }
+    }
+    if ($format == 'html') {
+      $out .= "</tbody></table>";
+    } else if ($format == 'tab') {
+      $out = str_replace("\t\n", "\n", $out);
+    }
+
+    return $out;
+  }
+
+  static function macro_RunReport($name, $url_style_params = null) {
+    $params = array();
+    parse_str($url_style_params, $params);
+    $rep = self::loadBySummary($name);
+    if ($rep) {
+      if (MTrackACL::hasAllRights("report:" . $rep->rid, 'read')) {
+        return $rep->renderReport($rep->query, $params);
+      } else {
+        return "Not authorized to run report $name";
+      }
+    } else {
+      return "Unable to find report $name";
+    }
+  }
+
+  static function parseQuery()
+  {
+    $macro_params = array(
+      'group' => true,
+      'col' => true,
+      'order' => true,
+      'desc' => true,
+      'format' => true,
+      'compact' => true,
+      'count' => true,
+      'max' => true
+    );
+
+    $mparams = array(
+      'col' => array('ticket', 'summary', 'state', 
+                'priority',
+                'owner', 'type', 'component',
+                'remaining'),
+      'order' => array('pri.value'),
+      'desc' => array('0'),
+    );
+    $params = array();
+
+    $args = func_get_args();
+    foreach ($args as $arg) {
+      if ($arg === null) continue;
+      $p = explode('&', $arg);
+
+      foreach ($p as $a) {
+        $a = urldecode($a);
+        preg_match('/^([a-zA-Z_]+)(!?(?:=|~=|\^=|\$=))(.*)$/', $a, $M);
+
+        $k = $M[1];
+        $op = $M[2];
+        $pat = explode('|', $M[3]);
+
+        if (isset($macro_params[$k])) {
+          $mparams[$k] = $pat;
+        } else if (isset($params[$k])) {
+          if ($params[$k][0] == $op) {
+            // compatible operator; add $pat to possible set
+            $params[$k][1] = array_merge($pat, $params[$k][1]);
+          } else {
+            // ignore
+          }
+        } else {
+          $params[$k] = array($op, $pat);
+        }
+      }
+    }
+    return array($params, $mparams);
+  }
+
+  static function macro_TicketQuery()
+  {
+    $args = func_get_args();
+    list($params, $mparams) = call_user_func_array(array(
+      'MTrackReport', 'parseQuery'), $args);
+
+    /* compose that info into a query */
+    $sql = 'select ';
+
+    $colmap = array(
+      'ticket' => '(case when t.nsident is null then t.tid else t.nsident end) as ticket',
+      'component' => '(select mtrack_group_concat(name) from ticket_components
+            tcm left join components c on (tcm.compid = c.compid)
+            where tcm.tid = t.tid) as component',
+      'keyword' => '(select mtrack_group_concat(keyword) from ticket_keywords
+            tk left join keywords k on (tk.kid = k.kid)
+            where tk.tid = t.tid) as keyword',
+      'type' => 'classification as type',
+      'remaining' => "(case when t.status = 'closed' then 0 else (t.estimated - (select sum(expended) from effort where effort.tid = t.tid)) end) as remaining",
+      'state' => "(case when t.status = 'closed' then coalesce(t.resolution, 'closed') else t.status end) as state",
+      'milestone' => '(select mtrack_group_concat(name) from ticket_milestones
+            tmm left join milestones tmmm on (tmm.mid = tmmm.mid)
+            where tmm.tid = t.tid) as milestone',
+    );
+
+    $cols = array(
+     ' pri.value as __color__ ',
+     ' (case when t.nsident is null then t.tid else t.nsident end) as ticket ',
+     " t.status as __status__ ",
+    );
+
+    foreach ($mparams['col'] as $colname) {
+      if ($colname == 'ticket') {
+        continue;
+      }
+      if (isset($colmap[$colname])) {
+        $cols[$colname] = $colmap[$colname];
+      } else {
+        if (!preg_match("/^[a-zA-Z_]+$/", $colname)) {
+          throw new Exception("column name $colname is invalid");
+        }
+        $cols[$colname] = $colname;
+      }
+    }
+
+    $sql .= join(', ', $cols);
+   
+    if (!isset($params['milestone'])) {
+      $sql .= <<<SQL
+
+FROM
+tickets t 
+left join priorities pri on (t.priority = pri.priorityname)
+left join severities sev on (t.severity = sev.sevname)
+WHERE
+ 1 = 1
+
+SQL;
+    } else {
+      $sql .= <<<SQL
+
+FROM milestones m 
+left join ticket_milestones tm on (m.mid = tm.mid)
+left join tickets t on (tm.tid = t.tid)
+left join priorities pri on (t.priority = pri.priorityname)
+left join severities sev on (t.severity = sev.sevname)
+WHERE
+ 1 = 1
+
+SQL;
+    }
+
+    $critmap = array(
+      'milestone' => 'm.name',
+      'tid' => 't.tid',
+      'id' => 't.tid',
+      'ticket' => 't.tid',
+    );
+
+    foreach ($params as $k => $v) {
+      list($op, $values) = $v;
+
+      if (isset($critmap[$k])) {
+        $k = $critmap[$k];
+      }
+
+      $sql .= " AND ";
+
+      if ($op[0] == '!') {
+        $sql .= " NOT ";
+        $op = substr($op, 1);
+      }
+      $sql .= "(";
+
+      if ($op == '=') {
+
+        if ($k == 't.tid' && count($values) == 1 &&
+            preg_match('/[,-]/', $values[0])) {
+
+          $crit = array();
+          foreach (explode(',', $values[0]) as $range) {
+            list($rfrom, $rto) = explode('-', $range, 2);
+            $type = 'integer';
+            if (!ctype_digit($rfrom)) {
+              $rfrom = MTrackDB::esc($rfrom);
+              $type = 'text';
+            }
+            if ($rto) {
+              if (!ctype_digit($rto)) {
+                $rto = MTrackDB::esc($rto);
+                $type = 'text';
+              }
+              $crit[] = "(cast(t.tid as $type) between $rfrom and $rto)";
+              $crit[] = "(cast(t.nsident as $type) between $rfrom and $rto)";
+            } else {
+              $crit[] = "(t.tid = $rfrom)";
+              $crit[] = "(t.nsident = $rfrom)";
+            }
+          }
+          $sql .= join(' OR ', $crit);
+        } else if (count($values) == 1) {
+          $sql .= " $k = " . MTrackDB::esc($values[0]) . " ";
+        } else {
+
+          $sql .= " $k in (";
+          foreach ($values as $i => $val) {
+            $values[$i] = MTrackDB::esc($val);
+          }
+          $sql .= join(', ', $values) . ") ";
+        }
+      } else {
+        /* variations on like */
+        if ($op == '~=') {
+          $start = '%';
+          $end = '%';
+        } else if ($op == '^=') {
+          $start = '';
+          $end = '%';
+        } else {
+          $start = '%';
+          $end = '';
+        }
+      
+        $crit = array();
+
+        foreach ($values as $val) {
+          $crit[] = "($k LIKE " . MTrackDB::esc("$start$val$end") . ")";
+        }
+        $sql .= join(" OR ", $crit);
+      }
+
+      $sql .= ") ";
+
+    }
+    if (isset($mparams['group'])) {
+      $g = $mparams['group'][0];
+      if (!ctype_alpha($g)) {
+        throw new Exception("group $g is not alpha");
+      }
+      $sql .= ' GROUP BY ' . $g;
+    }
+
+    if (isset($mparams['order'])) {
+      $k = $mparams['order'][0];
+      if ($k == 'tid') {
+        $k = 't.tid';
+      }
+
+      $sql .= ' ORDER BY ' . $k;
+      if (isset($mparams['desc']) && $mparams['desc'][0]) {
+        $sql .= ' DESC';
+      }
+    }
+
+    if (isset($mparams['max'])) {
+      $sql .= ' LIMIT ' . (int)$mparams['max'][0];
+    }
+#    return htmlentities($sql);
+#    return var_export($sql, true); 
+
+    return self::renderReport($sql);
+
+
+  }
+};
+
+MTrackWiki::register_macro('RunReport',
+  array('MTrackReport', 'macro_RunReport'));
+
+MTrackWiki::register_macro('TicketQuery',
+  array('MTrackReport', 'macro_TicketQuery'));
+
+MTrackACL::registerAncestry('report', 'Reports');
+
diff --git a/inc/scm.php b/inc/scm.php
new file mode 100644 (file)
index 0000000..43961ea
--- /dev/null
@@ -0,0 +1,703 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackSCMEvent {
+  /** Revision or changeset identifier for this particular file */
+  public $rev;
+
+  /** commit message associated with this revision */
+  public $changelog;
+
+  /** who committed this revision */
+  public $changeby;
+
+  /** when this revision was committed */
+  public $ctime;
+
+  /** files affected in this event; may be null, but otherwise
+   * will be an array of MTrackSCMFileEvent */
+  public $files;
+}
+
+class MTrackSCMFileEvent {
+  /** Name of affected file */
+  public $name;
+  /** Change status indicator */
+  public $status;
+
+  /** when used in a string context, just return the filename.
+   * This simplifies explicit object vs. string interpretation
+   * throughout the SCM layer */
+  function __toString() {
+    return $this->name;
+  }
+}
+
+class MTrackSCMAnnotation {
+  /** Revision of changeset identifier for when line was changed */
+  public $rev;
+
+  /** who made the change */
+  public $changeby;
+
+  /** the content from that line of the file.
+   * This is null unless $include_line_content was set to true when annotate()
+   * was called */
+  public $line;
+}
+
+abstract class MTrackSCMFile {
+  /** reference to the associated MTrackSCM object */
+  public $repo;
+
+  /** full path to file, with a leading slash (which represents
+   * the root of its respective repo */
+  public $name;
+
+  /** if true, this file represents a directory */
+  public $is_dir = false;
+
+  /** revision */
+  public $rev;
+
+  function __construct(MTrackSCM $repo, $name, $rev, $is_dir = false)
+  {
+    $this->repo = $repo;
+    $this->name = $name;
+    $this->rev = $rev;
+    $this->is_dir = $is_dir;
+  }
+
+  /** Returns an MTrackSCMEvent corresponding to this revision of
+   * the file */
+  abstract public function getChangeEvent();
+
+  /** Returns a stream representing the contents of the file at
+   * this revision */
+  abstract public function cat();
+
+  /** Returns an array of MTrackSCMAnnotation objects that correspond to
+   * each line of file content, annotating when the line was last
+   * changed.  The array is keyed by line number, 1-based. */
+  abstract public function annotate($include_line_content = false);
+}
+
+abstract class MTrackSCMWorkingCopy {
+  public $dir;
+
+  /** returns the root dir of the working copy */
+  function getDir() {
+    return $this->dir;
+  }
+
+  /** add a file to the working copy */
+  abstract function addFile($path);
+  /** removes a file from the working copy */
+  abstract function delFile($path);
+  /** commit changes that are pending in the working copy */
+  abstract function commit(MTrackChangeset $CS);
+  /** get an MTrackSCMFile representation of a file */
+  abstract function getFile($path);
+
+  /** enumerates files in a path in the working copy */
+  function enumFiles($path)
+  {
+    return scandir($this->dir . DIRECTORY_SEPARATOR . $path);
+  }
+
+  /** determines if a file exists in the working copy */
+  function file_exists($path)
+  {
+    return file_exists($this->dir . DIRECTORY_SEPARATOR . $path);
+  }
+
+  function __destruct()
+  {
+    if (strlen($this->dir) > 1) {
+      mtrack_rmdir($this->dir);
+    }
+  }
+}
+
+abstract class MTrackSCM {
+  static $repos = array();
+
+  static function factory(&$repopath) {
+    /* [ / owner type rest ] */
+    $bits = explode('/', $repopath, 4);
+    if (count($bits) < 3) {
+      throw new Exception("Invalid repo $repopath");
+    }
+    array_shift($bits);
+    list($owner, $type) = $bits;
+    $repo = "$owner/$type";
+
+    $r = MTrackRepo::loadByName($repo);
+    if (!$r) {
+      throw new Exception("invalid repo $repo");
+    }
+    $repopath = isset($bits[2]) ? $bits[2] : '';
+    return $r;
+  }
+
+  /** Returns an array keyed by possible branch names.
+   * The data associated with the branches is implementation
+   * defined.
+   * If the SCM does not have a concept of first-class branch
+   * objects, this function returns null */
+  abstract public function getBranches();
+
+  /** Returns an array keyed by possible tag names.
+   * The data associated with the tags is implementation
+   * defined.
+   * If the SCM does not have a concept of first-class tag
+   * objects, this function returns null */
+  abstract public function getTags();
+
+  /** Enumerates the files/dirs that are present in the specified
+   * location of the repository that match the specified revision,
+   * branch or tag information.  If no revision, branch or tag is
+   * specified, then the appropriate default is assumed.
+   *
+   * The second and third parameters are optional; the second
+   * parameter is one of 'rev', 'branch', or 'tag', and if specifed
+   * the third parameter must be the corresponding revision, branch
+   * or tag identifier.
+   *
+   * The return value is an array of MTrackSCMFile objects present
+   * at that location/revision of the repository.
+   */
+  abstract public function readdir($path, $object = null, $ident = null);
+
+  /** Queries information on a specific file in the repository.
+   *
+   * Parameters are as for readdir() above.
+   *
+   * This function returns a single MTrackSCMFile for the location
+   * in question.
+   */
+  abstract public function file($path, $object = null, $ident = null);
+
+  /** Queries history for a particular location in the repo.
+   *
+   * Parameters are as for readdir() above, except that path can be
+   * left unspecified to query the history for the entire repo.
+   *
+   * The limit parameter limits the number of entries returned; it it is
+   * a number, it specifies the number of events, otherwise it is assumed
+   * to be a date in the past; only events since that date will be returned.
+   *
+   * Returns an array of MTrackSCMEvent objects.
+   */
+  abstract public function history($path, $limit = null, $object = null,
+    $ident = null);
+
+  /** Obtain the diff text representing a change to a file.
+   *
+   * You may optionally provide one or two revisions as context.
+   *
+   * If no revisions are passed in, then the change associated
+   * with the location will be assumed.
+   *
+   * If one revision is passed, then the change associated with
+   * that event will be assumed.
+   *
+   * If two revisions are passed, then the difference between
+   * the two events will be assumed.
+   */
+  abstract public function diff($path, $from = null, $to = null);
+
+  /** Determine the next and previous revisions for a given
+   * changeset.
+   *
+   * Returns an array: the 0th element is an array of prior revisions,
+   * and the 1st element is an array of successor revisions.
+   *
+   * There will usually be one prior and one successor revision for a
+   * given change, but some SCMs will return multiples in the case of
+   * merges.
+   */
+  abstract public function getRelatedChanges($revision);
+
+  /** Returns a working copy object for the repo
+   *
+   * The intended purpose is to support wiki page modifications, and
+   * as such, is not meant to be an especially efficient means to do so.
+   */
+  abstract public function getWorkingCopy();
+
+  /** Returns the default 'root' location in the repository.
+   * For SCMs that have a concept of branches, this is the empty string.
+   * For SCMs like SVN, this is the trunk dir */
+  public function getDefaultRoot() {
+    return '';
+  }
+
+  /** Returns meta information about the SCM type; this is used in the
+   * UI and tooling to let the user know their options.
+   *
+   * Returns an array with the following keys:
+   * 'name' => 'Mercurial', // human displayable name
+   * 'tools' => array('hg'), // list of tools to find during setup
+   */
+  abstract public function getSCMMetaData();
+
+  /* takes an MTrackSCM as a parameter because in some bootstrapping
+   * cases, we're actually MTrackRepo and not the end-class.
+   * MTrackRepo calls the end-class method and passes itself in for
+   * context */
+  public function reconcileRepoSettings(MTrackSCM $r) {
+    throw new Exception(
+      "Creating/updating a repo of type $this->scmtype is not implemented");
+  }
+
+  static function makeBreadcrumbs($pi) {
+    if (!strlen($pi)) {
+      $pi = '/';
+    }
+    if ($pi == '/') {
+      $crumbs = array('');
+    } else {
+      $crumbs = explode('/', $pi);
+    }
+    return $crumbs;
+  }
+
+  static function makeDisplayName($data) {
+    $parent = '';
+    $name = '';
+    if (is_object($data)) {
+      $parent = $data->parent;
+      $name = $data->shortname;
+    } else if (is_array($data)) {
+      $parent = $data['parent'];
+      $name = $data['shortname'];
+    }
+    if ($parent) {
+      list($type, $owner) = explode(':', $parent);
+      return "$owner/$name";
+    }
+    return "default/$name";
+  }
+
+  public function getBrowseRootName() {
+    return self::makeDisplayName($this);
+  }
+
+  public function resolveRevision($rev, $object, $ident) {
+    if ($rev !== null) {
+      return $rev;
+    }
+    if ($object === null) {
+      return null;
+    }
+    switch ($object) {
+      case 'rev':
+        $rev = $ident;
+        break;
+      case 'branch':
+        $branches = $this->getBranches();
+        $rev = isset($branches[$ident]) ? $branches[$ident] : null;
+        break;
+      case 'tag':
+        $tags = $this->getTags();
+        $rev = isset($tags[$ident]) ? $tags[$ident] : null;
+        break;
+    }
+    if ($rev === null) {
+      throw new Exception(
+        "don't know which revision to use ($rev,$object,$ident)");
+    }
+    return $rev;
+  }
+}
+MTrackACL::registerAncestry('repo', 'Browser');
+MTrackWatch::registerEventTypes('repo', array(
+  'ticket' => 'Tickets',
+  'changeset' => 'Code changes'
+));
+
+class MTrackRepo extends MTrackSCM {
+  public $repoid = null;
+  public $shortname = null;
+  public $scmtype = null;
+  public $repopath = null;
+  public $browserurl = null;
+  public $browsertype = null;
+  public $description = null;
+  public $parent = '';
+  public $clonedfrom = null;
+  public $serverurl = null;
+  private $links_to_add = array();
+  private $links_to_remove = array();
+  private $links = null;
+  static $scms = array();
+
+  static function registerSCM($scmtype, $classname) {
+    self::$scms[$scmtype] = $classname;
+  }
+  static function getAvailableSCMs() {
+    $ret = array();
+    foreach (self::$scms as $t => $classname) {
+      $o = new $classname;
+      $ret[$t] = $o;
+    }
+    return $ret;
+  }
+
+  public function reconcileRepoSettings() {
+    if (!isset(self::$scms[$this->scmtype])) {
+      throw new Exception("invalid scm type $this->scmtype");
+    }
+    $c = self::$scms[$this->scmtype];
+    $s = new $c;
+    $s->reconcileRepoSettings($this);
+  }
+
+  public function getSCMMetaData() {
+    return null;
+  }
+
+  static function loadById($id) {
+    list($row) = MTrackDB::q(
+      'select repoid, scmtype from repos where repoid = ?',
+      $id)->fetchAll();
+    if (isset($row[0])) {
+      $type = $row[1];
+      if (isset(self::$scms[$type])) {
+        $class = self::$scms[$type];
+        return new $class($row[0]);
+      }
+      throw new Exception("unsupported repo type $type");
+    }
+    return null;
+  }
+
+  static function loadByName($name) {
+    $bits = explode('/', $name);
+    if (count($bits) > 1 && $bits[0] == 'default') {
+      array_shift($bits);
+      $name = $bits[0];
+    }
+    if (count($bits) > 1) {
+      /* wez/reponame -> per user repo */
+      $u = "user:$bits[0]";
+      $p = "project:$bits[0]";
+      $rows = MTrackDB::q(
+        'select repoid, scmtype from repos where shortname = ? and (parent = ? OR parent = ?)',
+        $bits[1], $u, $p)->fetchAll();
+    } else {
+      $rows = MTrackDB::q(
+        "select repoid, scmtype from repos where shortname = ? and parent =''",
+        $name)->fetchAll();
+    }
+    if (is_array($rows) && isset($rows[0])) {
+      $row = $rows[0];
+      if (isset($row[0])) {
+        $type = $row[1];
+        if (isset(self::$scms[$type])) {
+          $class = self::$scms[$type];
+          return new $class($row[0]);
+        }
+        throw new Exception("unsupported repo type $type");
+      }
+    }
+    return null;
+  }
+
+  function getServerURL() {
+    if ($this->serverurl) {
+      return $this->serverurl;
+    }
+    $url = MTrackConfig::get('repos', "$this->scmtype.serverurl");
+    if ($url) {
+      return $url . $this->getBrowseRootName();
+    }
+    return null;
+  }
+
+  function getCheckoutCommand() {
+    $url = $this->getServerURL();
+    if (strlen($url)) {
+      return $this->scmtype . ' clone ' . $this->getServerURL();
+    }
+    return null;
+  }
+
+  function canFork() {
+    return false;
+  }
+
+  static function loadByLocation($path) {
+    list($row) = MTrackDB::q('select repoid, scmtype from repos where repopath = ?', $path)->fetchAll();
+    if (isset($row[0])) {
+      $type = $row[1];
+      if (isset(self::$scms[$type])) {
+        $class = self::$scms[$type];
+        return new $class($row[0]);
+      }
+      throw new Exception("unsupported repo type $type");
+    }
+    return null;
+  }
+
+  public function getWorkingCopy() {
+    throw new Exception("cannot getWorkingCopy from a generic repo object");
+  }
+
+  function __construct($id = null) {
+    if ($id !== null) {
+      list($row) = MTrackDB::q(
+                    'select * from repos where repoid = ?',
+                    $id)->fetchAll();
+      if (isset($row[0])) {
+        $this->repoid = $row['repoid'];
+        $this->shortname = $row['shortname'];
+        $this->scmtype = $row['scmtype'];
+        $this->repopath = $row['repopath'];
+        $this->browserurl = $row['browserurl'];
+        $this->browsertype = $row['browsertype'];
+        $this->description = $row['description'];
+        $this->parent = $row['parent'];
+        $this->clonedfrom = $row['clonedfrom'];
+        $this->serverurl = $row['serverurl'];
+        return;
+      }
+      throw new Exception("unable to find repo with id = $id");
+    }
+  }
+
+  function deleteRepo(MTrackChangeset $CS) {
+    MTrackDB::q('delete from repos where repoid = ?', $this->repoid);
+    mtrack_rmdir($this->repopath);
+  }
+
+  function save(MTrackChangeset $CS) {
+    if (!isset(self::$scms[$this->scmtype])) {
+      throw new Exception("unsupported repo type " . $this->scmtype);
+    }
+
+    if ($this->repoid) {
+      list($row) = MTrackDB::q(
+                    'select * from repos where repoid = ?',
+                    $this->repoid)->fetchAll();
+      $old = $row;
+      MTrackDB::q(
+          'update repos set shortname = ?, scmtype = ?, repopath = ?,
+            browserurl = ?, browsertype = ?, description = ?,
+            parent = ?, serverurl = ?, clonedfrom = ? where repoid = ?',
+          $this->shortname, $this->scmtype, $this->repopath,
+          $this->browserurl, $this->browsertype, $this->description,
+          $this->parent, $this->serverurl, $this->clonedfrom, $this->repoid);
+    } else {
+      $acl = null;
+
+      if (!strlen($this->repopath)) {
+        if (!MTrackConfig::get('repos', 'allow_user_repo_creation')) {
+          throw new Exception("configuration does not allow repo creation");
+        }
+        $repodir = MTrackConfig::get('repos', 'basedir');
+        if ($repodir == null) {
+          $repodir = MTrackConfig::get('core', 'vardir') . '/repos';
+        }
+        if (!is_dir($repodir)) {
+          mkdir($repodir);
+        }
+
+        if (!$this->parent) {
+          $owner = mtrack_canon_username(MTrackAuth::whoami());
+          $this->parent = 'user:' . $owner;
+        } else {
+          list($type, $owner) = explode(':', $this->parent, 2);
+          switch ($type) {
+            case 'project':
+              $P = MTrackProject::loadByName($owner);
+              if (!$P) {
+                throw new Exception("invalid project $owner");
+              }
+              MTrackACL::requireAllRights("project:$P->projid", 'modify');
+              break;
+            case 'user':
+              if ($owner != mtrack_canon_username(MTrackAuth::whoami())) {
+                throw new Exception("can't make a repo for another user");
+              }
+              break;
+            default:
+              throw new Exception("invalid parent ($this->parent)");
+          }
+        }
+        if (preg_match("/[^a-zA-Z0-9_.-]/", $owner)) {
+          throw new Exception("$owner must not contain special characters");
+        }
+        $this->repopath = $repodir . DIRECTORY_SEPARATOR . $owner;
+        if (!is_dir($this->repopath)) {
+          mkdir($this->repopath);
+        }
+        $this->repopath .= DIRECTORY_SEPARATOR . $this->shortname;
+
+        /* default ACL is allow user all rights, block everybody else */
+        $acl = array(
+          array($owner, 'read', 1),
+          array($owner, 'modify', 1),
+          array($owner, 'delete', 1),
+          array($owner, 'checkout', 1),
+          array($owner, 'commit', 1),
+          array('*', 'read', 0),
+          array('*', 'modify', 0),
+          array('*', 'delete', 0),
+          array('*', 'checkout', 0),
+          array('*', 'commit', 0),
+        );
+      }
+
+      MTrackDB::q('insert into repos (shortname, scmtype,
+          repopath, browserurl, browsertype, description, parent,
+          serverurl, clonedfrom)
+          values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+          $this->shortname, $this->scmtype, $this->repopath,
+          $this->browserurl, $this->browsertype, $this->description,
+          $this->parent, $this->serverurl, $this->clonedfrom);
+
+      $this->repoid = MTrackDB::lastInsertId('repos', 'repoid');
+      $old = null;
+
+      if ($acl !== null) {
+        MTrackACL::setACL("repo:$this->repoid", 0, $acl);
+        $me = mtrack_canon_username(MTrackAuth::whoami());
+        foreach (array('ticket', 'changeset') as $e) {
+          MTrackDB::q(
+            'insert into watches (otype, oid, userid, medium, event, active) values (?, ?, ?, ?, ?, 1)',
+          'repo', $this->repoid, $me, 'email', $e);
+        }
+      }
+    }
+    $this->reconcileRepoSettings();
+    if (!$this->parent) {
+      /* for SSH access, populate a symlink from the repos basedir to the
+       * actual path for this repo */
+      $repodir = MTrackConfig::get('repos', 'basedir');
+      if ($repodir == null) {
+        $repodir = MTrackConfig::get('core', 'vardir') . '/repos';
+      }
+      if (!is_dir($repodir)) {
+        mkdir($repodir);
+      }
+      $repodir .= '/default';
+      if (!is_dir($repodir)) {
+        mkdir($repodir);
+      }
+      $repodir .= '/' . $this->shortname;
+      if (!file_exists($repodir)) {
+        symlink($this->repopath, $repodir);
+      } else if (is_link($repodir) && readlink($repodir) != $this->repopath) {
+        unlink($repodir);
+        symlink($this->repopath, $repodir);
+      }
+    }
+    $CS->add("repo:" . $this->repoid . ":shortname", $old['shortname'], $this->shortname);
+    $CS->add("repo:" . $this->repoid . ":scmtype", $old['scmtype'], $this->scmtype);
+    $CS->add("repo:" . $this->repoid . ":repopath", $old['repopath'], $this->repopath);
+    $CS->add("repo:" . $this->repoid . ":browserurl", $old['browserurl'], $this->browserurl);
+    $CS->add("repo:" . $this->repoid . ":browsertype", $old['browsertype'], $this->browsertype);
+    $CS->add("repo:" . $this->repoid . ":description", $old['description'], $this->description);
+    $CS->add("repo:" . $this->repoid . ":parent", $old['parent'], $this->parent);
+    $CS->add("repo:" . $this->repoid . ":clonedfrom", $old['clonedfrom'], $this->clonedfrom);
+    $CS->add("repo:" . $this->repoid . ":serverurl", $old['serverurl'], $this->serverurl);
+
+    foreach ($this->links_to_add as $link) {
+      MTrackDB::q('insert into project_repo_link (projid, repoid, repopathregex) values (?, ?, ?)', $link[0], $this->repoid, $link[1]);
+    }
+    foreach ($this->links_to_remove as $linkid) {
+      MTrackDB::q('delete from project_repo_link where repoid = ? and linkid = ?', $this->repoid, $linkid);
+    }
+  }
+
+  function getLinks()
+  {
+    if ($this->links === null) {
+      $this->links = array();
+      foreach (MTrackDB::q('select linkid, projid, repopathregex
+          from project_repo_link where repoid = ? order by repopathregex',
+          $this->repoid)->fetchAll() as $row) {
+        $this->links[$row[0]] = array($row[1], $row[2]);
+      }
+    }
+    return $this->links;
+  }
+
+  function addLink($proj, $regex)
+  {
+    if ($proj instanceof MTrackProject) {
+      $this->links_to_add[] = array($proj->projid, $regex);
+    } else {
+      $this->links_to_add[] = array($proj, $regex);
+    }
+  }
+
+  function removeLink($linkid)
+  {
+    $this->links_to_remove[$linkid] = $linkid;
+  }
+
+  public function getBranches() {}
+  public function getTags() {}
+  public function readdir($path, $object = null, $ident = null) {}
+  public function file($path, $object = null, $ident = null) {}
+  public function history($path, $limit = null, $object = null, $ident = null){}
+  public function diff($path, $from = null, $to = null) {}
+  public function getRelatedChanges($revision) {}
+
+  function projectFromPath($filename) {
+    static $links = array();
+    if (!isset($links[$this->repoid]) || $links[$this->repoid] === null) {
+      $links[$this->repoid] = array();
+      foreach (MTrackDB::q(
+        'select projid, repopathregex from project_repo_link where repoid = ?',
+            $this->repoid) as $row) {
+        $re = str_replace('/', '\\/', $row[1]);
+        $links[$this->repoid][] = array($row[0], "/$re/");
+      }
+    }
+    if (is_array($filename)) {
+      $proj_incidence = array();
+      foreach ($filename as $file) {
+        $proj = $this->projectFromPath($file);
+        if ($proj === null) continue;
+        if (isset($proj_incidence[$proj])) {
+          $proj_incidence[$proj]++;
+        } else {
+          $proj_incidence[$proj] = 1;
+        }
+      }
+      $the_proj = null;
+      $the_proj_count = 0;
+      foreach ($proj_incidence as $proj => $count) {
+        if ($count > $the_proj_count) {
+          $the_proj_count = $count;
+          $the_proj = $proj;
+        }
+      }
+      return $the_proj;
+    }
+
+    if ($filename instanceof MTrackSCMFileEvent) {
+      $filename = $filename->name;
+    }
+
+    // walk through the regexes; take the longest match as definitive
+    $longest = null;
+    $longest_id = null;
+    if ($filename[0] != '/') {
+      $filename = '/' . $filename;
+    }
+    foreach ($links[$this->repoid] as $link) {
+      if (preg_match($link[1], $filename, $M)) {
+        if (strlen($M[0]) > strlen($longest)) {
+          $longest = $M[0];
+          $longest_id = $link[0];
+        }
+      }
+    }
+    return $longest_id;
+  }
+}
diff --git a/inc/scm/git.php b/inc/scm/git.php
new file mode 100644 (file)
index 0000000..2e5cf6b
--- /dev/null
@@ -0,0 +1,534 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+/* Git SCM browsing */
+
+class MTrackSCMFileGit extends MTrackSCMFile {
+  public $name;
+  public $rev;
+  public $is_dir;
+  public $repo;
+
+  function __construct(MTrackSCM $repo, $name, $rev, $is_dir = false)
+  {
+    $this->repo = $repo;
+    $this->name = $name;
+    $this->rev = $rev;
+    $this->is_dir = $is_dir;
+  }
+
+  public function getChangeEvent()
+  {
+    list($ent) = $this->repo->history($this->name, 1, 'rev', $this->rev);
+    return $ent;
+  }
+
+  function cat()
+  {
+    // There may be a better way...
+    // ls-tree to determine the hash of the file from this change:
+    $fp = $this->repo->git('ls-tree', $this->rev, $this->name);
+    $line = fgets($fp);
+    $fp = null;
+    list($mode, $type, $hash, $name) = preg_split("/\s+/", $line);
+
+    // now we can cat that blob
+    return $this->repo->git('cat-file', 'blob', $hash);
+  }
+
+  function annotate($include_line_content = false)
+  {
+    if ($this->repo->gitdir == $this->repo->repopath) {
+      // For bare repos, we can't run annotate, so we need to make a clone
+      // with a work tree.  This relies on local clones being a cheap operation
+      $wc = new MTrackWCGit($this->repo);
+      $wc->push = false;
+      $fp = $wc->git('annotate', '-p', $this->name, $this->rev);
+    } else {
+      $fp = $this->repo->git('annotate', '-p', $this->name, $this->rev);
+    }
+    $i = 1;
+    $ann = array();
+    $meta = array();
+    while ($line = fgets($fp)) {
+//      echo htmlentities($line), "<br>\n";
+      if (!strncmp($line, "\t", 1)) {
+        $A = new MTrackSCMAnnotation;
+        if (isset($meta['author-mail']) &&
+            strpos($meta['author-mail'], '@')) {
+          $A->changeby = $meta['author'] . ' ' . $meta['author-mail'];
+        } else {
+          $A->changeby = $meta['author'];
+        }
+        $A->rev = $meta['rev'];
+        if ($include_line_content) {
+          $A->line = substr($line, 1);
+        }
+        $ann[$i++] = $A;
+        continue;
+      }
+      if (preg_match("/^([a-f0-9]+)\s[a-f0-9]+\s[a-f0-9]+\s[a-f0-9]+$/",
+          $line, $M)) {
+        $meta['rev'] = $M[1];
+      } else if (preg_match("/^(\S+)\s*(.*)$/", $line, $M)) {
+        $name = $M[1];
+        $value = $M[2];
+        $meta[$name] = $value;
+      }
+    }
+    return $ann;
+  }
+}
+
+class MTrackWCGit extends MTrackSCMWorkingCopy {
+  private $repo;
+  public $push = true;
+
+  function __construct(MTrackRepo $repo) {
+    $this->dir = mtrack_make_temp_dir();
+    $this->repo = $repo;
+
+    mtrack_run_tool('git', 'string',
+        array('clone', $this->repo->repopath, $this->dir)
+    );
+  }
+
+  function __destruct() {
+    if ($this->push) {
+      echo stream_get_contents($this->git('push'));
+    }
+    mtrack_rmdir($this->dir);
+  }
+
+  function getFile($path)
+  {
+    return $this->repo->file($path);
+  }
+
+  function addFile($path)
+  {
+    $this->git('add', $path);
+  }
+
+  function delFile($path)
+  {
+    $this->git('rm', '-f', $path);
+  }
+
+  function commit(MTrackChangeset $CS)
+  {
+    if ($CS->when) {
+      $d = strtotime($CS->when);
+      putenv("GIT_AUTHOR_DATE=$d -0000");
+    } else {
+      putenv("GIT_AUTHOR_DATE=");
+    }
+    $reason = trim($CS->reason);
+    if (!strlen($reason)) {
+      $reason = 'Changed';
+    }
+    putenv("GIT_AUTHOR_NAME=$CS->who");
+    putenv("GIT_AUTHOR_EMAIL=$CS->who");
+    stream_get_contents($this->git('commit', '-a',
+      '-m', $reason
+      )
+    );
+  }
+
+  function git()
+  {
+    $args = func_get_args();
+    $a = array("--git-dir=$this->dir/.git", "--work-tree=$this->dir");
+    foreach ($args as $arg) {
+      $a[] = $arg;
+    }
+
+    return mtrack_run_tool('git', 'read', $a);
+  }
+}
+
+class MTrackSCMGit extends MTrackRepo {
+  protected $branches = null;
+  protected $tags = null;
+  public $gitdir = null;
+
+  public function getSCMMetaData() {
+    return array(
+      'name' => 'Git',
+      'tools' => array('git'),
+    );
+  }
+
+  function __construct($id = null) {
+    parent::__construct($id);
+    if ($id !== null) {
+      /* transparently handle bare vs. non bare repos */
+      $this->gitdir = $this->repopath;
+      if (is_dir("$this->repopath/.git")) {
+        $this->gitdir .= "/.git";
+      }
+    }
+  }
+
+  function getServerURL() {
+    $url = parent::getServerURL();
+    if ($url) return $url;
+    $url = MTrackConfig::get('repos', 'serverurl');
+    if ($url) {
+      return "$url:" . $this->getBrowseRootName();
+    }
+    return null;
+  }
+
+  public function reconcileRepoSettings(MTrackSCM $r = null) {
+    if ($r == null) {
+      $r = $this;
+    }
+
+    if (!is_dir($r->repopath)) {
+      $userdata = MTrackAuth::getUserData(MTrackAuth::whoami());
+      $who = $userdata['email'];
+      putenv("GIT_AUTHOR_NAME=$who");
+      putenv("GIT_AUTHOR_EMAIL=$who");
+
+      if ($r->clonedfrom) {
+        $S = MTrackRepo::loadById($r->clonedfrom);
+
+        $stm = mtrack_run_tool('git', 'read',
+            array('clone', '--bare', $S->repopath, $r->repopath));
+        $out = stream_get_contents($stm);
+        if (pclose($stm)) {
+          throw new Exception("git init failed: $out");
+        }
+
+      } else {
+        /* a little peculiar, but bear with it.
+         * We need to have a bare repo so that git doesn't mess around
+         * trying to deal with a checkout in the repo dir.
+         * So we need to create two repos; one bare, one not bare.
+         * We populate the non-bare repo with a dummy file just to have
+         * something to commit, then push non-bare -> bare, and remove non-bare.
+         */
+
+        $stm = mtrack_run_tool('git', 'read',
+            array('init', '--bare', $r->repopath));
+        $out = stream_get_contents($stm);
+        if (pclose($stm)) {
+          throw new Exception("git init failed: $out");
+        }
+
+        $alt = "$r->repopath.MTRACKINIT";
+
+        $stm = mtrack_run_tool('git', 'read',
+            array('init', $alt));
+        $out = stream_get_contents($stm);
+        if (pclose($stm)) {
+          throw new Exception("git init failed: $out");
+        }
+
+        $dir = getcwd();
+        chdir($alt);
+
+        file_put_contents("$alt/.gitignore", "#\n");
+        $stm = mtrack_run_tool('git', 'read',
+            array('add', '.gitignore'));
+        $out = stream_get_contents($stm);
+        if (pclose($stm)) {
+          throw new Exception("git add .gitignore failed: $out");
+        }
+        $stm = mtrack_run_tool('git', 'read',
+            array('commit', '-a', '-m', 'init'));
+        $out = stream_get_contents($stm);
+        if (pclose($stm)) {
+          throw new Exception("git commit failed: $out");
+        }
+        $stm = mtrack_run_tool('git', 'read',
+            array('push', $r->repopath, 'master'));
+        $out = stream_get_contents($stm);
+        if (pclose($stm)) {
+          throw new Exception("git push failed: $out");
+        }
+        chdir($dir);
+        system("rm -rf $alt");
+      }
+
+      $php = MTrackConfig::get('tools', 'php');
+      $hook = realpath(dirname(__FILE__) . '/../../bin/git-commit-hook');
+      $conffile = realpath(MTrackConfig::getLocation());
+      foreach (array('pre', 'post') as $step) {
+        $script = <<<HOOK
+#!/bin/sh
+exec $php $hook $step $conffile
+
+HOOK;
+        $target = "$r->repopath/hooks/$step-receive";
+        if (file_put_contents("$target.mtrack", $script)) {
+          chmod("$target.mtrack", 0755);
+          rename("$target.mtrack", $target);
+        }
+      }
+    }
+
+    system("chmod -R 02777 $r->repopath");
+  }
+
+  function canFork() {
+    return true;
+  }
+
+
+  public function getBranches()
+  {
+    if ($this->branches !== null) {
+      return $this->branches;
+    }
+    $this->branches = array();
+    $fp = $this->git('branch', '--no-color', '--verbose');
+    while ($line = fgets($fp)) {
+      // * master 61e7e7d oneliner
+      $line = substr($line, 2);
+      list($branch, $rev) = preg_split('/\s+/', $line);
+      $this->branches[$branch] = $rev;
+    }
+    $fp = null;
+    return $this->branches;
+  }
+
+  public function getTags()
+  {
+    if ($this->tags !== null) {
+      return $this->tags;
+    }
+    $this->tags = array();
+    $fp = $this->git('tag');
+    while ($line = fgets($fp)) {
+      $line = trim($line);
+      $this->tags[$line] = $line;
+    }
+    $fp = null;
+    return $this->tags;
+  }
+
+  public function readdir($path, $object = null, $ident = null)
+  {
+    $res = array();
+
+    if ($object === null) {
+      $object = 'branch';
+      $ident = 'master';
+    }
+    $rev = $this->resolveRevision(null, $object, $ident);
+
+    if (strlen($path)) {
+      $path = rtrim($path, '/') . '/';
+    }
+
+    $fp = $this->git('ls-tree', $rev, $path);
+
+    $dirs = array();
+
+    while ($line = fgets($fp)) {
+      list($mode, $type, $hash, $name) = preg_split("/\s+/", $line);
+
+      $res[] = new MTrackSCMFileGit($this, "$name", $rev, $type == 'tree');
+    }
+    return $res;
+  }
+
+  public function file($path, $object = null, $ident = null)
+  {
+    if ($object == null) {
+      $branches = $this->getBranches();
+      if (isset($branches['master'])) {
+        $object = 'branch';
+        $ident = 'master';
+      } else {
+        // fresh/empty repo
+        return null;
+      }
+    }
+    $rev = $this->resolveRevision(null, $object, $ident);
+    return new MTrackSCMFileGit($this, $path, $rev);
+  }
+
+  public function history($path, $limit = null, $object = null, $ident = null)
+  {
+    $res = array();
+
+    $args = array();
+    if ($object !== null) {
+      $rev = $this->resolveRevision(null, $object, $ident);
+      $args[] = "$rev";
+    } else {
+      $args[] = "master";
+    }
+    if ($limit !== null) {
+      if (is_int($limit)) {
+        $args[] = "--max-count=$limit";
+      } else {
+        $args[] = "--since=$limit";
+      }
+    }
+    $args[] = "--no-color";
+    $args[] = "--name-status";
+    $args[] = "--date=rfc";
+
+    $path = ltrim($path, '/');
+
+    $fp = $this->git('log', $args, '--', $path);
+
+    $commits = array();
+    $commit = null;
+    while (true) {
+      $line = fgets($fp);
+      if ($line === false) {
+        if ($commit !== null) {
+          $commits[] = $commit;
+        }
+        break;
+      }
+      if (preg_match("/^commit/", $line)) {
+        if ($commit !== null) {
+          $commits[] = $commit;
+        }
+        $commit = $line;
+        continue;
+      }
+      $commit .= $line;
+    }
+
+    foreach ($commits as $commit) {
+      $ent = new MTrackSCMEvent;
+      $lines = explode("\n", $commit);
+      $line = array_shift($lines);
+
+      if (!preg_match("/^commit\s+(\S+)$/", $line, $M)) {
+        break;
+      }
+      $ent->rev = $M[1];
+
+      $ent->branches = array(); // FIXME
+      $ent->tags = array(); // FIXME
+      $ent->files = array();
+
+      while (count($lines)) {
+        $line = array_shift($lines);
+        if (!strlen($line)) {
+          break;
+        }
+        if (preg_match("/^(\S+):\s+(.*)\s*$/", $line, $M)) {
+          $k = $M[1];
+          $v = $M[2];
+
+          switch ($k) {
+            case 'Author':
+              $ent->changeby = $v;
+              break;
+            case 'Date':
+              $ts = strtotime($v);
+              $ent->ctime = MTrackDB::unixtime($ts);
+              break;
+          }
+        }
+      }
+
+      $ent->changelog = "";
+
+      if ($lines[0] == '') {
+        array_shift($lines);
+      }
+
+      while (count($lines)) {
+        $line = array_shift($lines);
+        if (strncmp($line, '    ', 4)) {
+          array_unshift($lines, $line);
+          break;
+        }
+        $line = substr($line, 4);
+        $ent->changelog .= $line . "\n";
+      }
+
+      if ($lines[0] == '') {
+        array_shift($lines);
+      }
+      foreach ($lines as $line) {
+        if (preg_match("/^(.+)\s+(\S+)\s*$/", $line, $M)) {
+          $f = new MTrackSCMFileEvent;
+          $f->name = $M[2];
+          $f->status = $M[1];
+          $ent->files[] = $f;
+        }
+      }
+
+      if (!count($ent->branches)) {
+        $ent->branches[] = 'master';
+      }
+
+      $res[] = $ent;
+    }
+    $fp = null;
+    return $res;
+  }
+
+  public function diff($path, $from = null, $to = null)
+  {
+    if ($path instanceof MTrackSCMFile) {
+      if ($from === null) {
+        $from = $path->rev;
+      }
+      $path = $path->name;
+    }
+    if ($to !== null) {
+      return $this->git('diff', "$from..$to", '--', $path);
+    }
+    return $this->git('diff', "$from^..$from", '--', $path);
+  }
+
+  public function getWorkingCopy()
+  {
+    return new MTrackWCGit($this);
+  }
+
+  public function getRelatedChanges($revision)
+  {
+    $parents = array();
+    $kids = array();
+
+    $fp = $this->git('rev-parse', "$revision^");
+    while (($line = fgets($fp)) !== false) {
+      $parents[] = trim($line);
+    }
+
+    // Ugh!: http://stackoverflow.com/questions/1761825/referencing-the-child-of-a-commit-in-git
+    $fp = $this->git('rev-list', '--all', '--parents');
+    while (($line = fgets($fp)) !== false) {
+      $hashes = preg_split("/\s+/", $line);
+      $kid = array_shift($hashes);
+      if (in_array($revision, $hashes)) {
+        $kids[] = $kid;
+      }
+    }
+
+    return array($parents, $kids);
+  }
+
+  function git()
+  {
+    $args = func_get_args();
+    $a = array(
+      "--git-dir=$this->gitdir"
+    );
+
+    if ($this->gitdir != $this->repopath) {
+      $a[] = "--work-tree=$this->repopath";
+    }
+    foreach ($args as $arg) {
+      $a[] = $arg;
+    }
+
+    return mtrack_run_tool('git', 'read', $a);
+  }
+}
+
+MTrackRepo::registerSCM('git', 'MTrackSCMGit');
+
diff --git a/inc/scm/hg.php b/inc/scm/hg.php
new file mode 100644 (file)
index 0000000..2a1931f
--- /dev/null
@@ -0,0 +1,471 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+/* Mercurial SCM browsing */
+
+class MTrackSCMFileHg extends MTrackSCMFile {
+  public $name;
+  public $rev;
+  public $is_dir;
+  public $repo;
+
+  function __construct(MTrackSCM $repo, $name, $rev, $is_dir = false)
+  {
+    $this->repo = $repo;
+    $this->name = $name;
+    $this->rev = $rev;
+    $this->is_dir = $is_dir;
+  }
+
+  public function _determineFileChangeEvent($repoid, $filename, $rev)
+  {
+    $repo = MTrackRepo::loadById($repoid);
+    $ents = $repo->history($filename, 1, 'rev', "$rev:0");
+    if (!count($ents)) {
+      throw new Exception("$filename is invalid");
+    }
+    return $ents[0];
+  }
+
+  public function getChangeEvent()
+  {
+    return mtrack_cache(
+      array('MTrackSCMFileHg', '_determineFileChangeEvent'),
+      array($this->repo->repoid, $this->name, $this->rev),
+      864000);
+  }
+
+  function cat()
+  {
+    return $this->repo->hg('cat', '-r', $this->rev, $this->name);
+  }
+
+  function annotate($include_line_content = false)
+  {
+    $i = 1;
+    $ann = array();
+    $fp = $this->repo->hg('annotate', '-r', $this->rev, '-uvc', $this->name);
+    while ($line = fgets($fp)) {
+      preg_match("/^\s*([^:]*)\s+([0-9a-fA-F]+): (.*)$/", $line, $M);
+      $A = new MTrackSCMAnnotation;
+      $A->changeby = $M[1];
+      $A->rev = $M[2];
+      if ($include_line_content) {
+        $A->line = $M[3];
+      }
+      $ann[$i++] = $A;
+    }
+    return $ann;
+  }
+}
+
+class MTrackWCHg extends MTrackSCMWorkingCopy {
+  private $repo;
+
+  function __construct(MTrackRepo $repo) {
+    $this->dir = mtrack_make_temp_dir();
+    $this->repo = $repo;
+
+    stream_get_contents($this->hg('init', $this->dir));
+    stream_get_contents($this->hg('pull', $this->repo->repopath));
+    stream_get_contents($this->hg('up'));
+  }
+
+  function __destruct() {
+
+    $a = array("-y", "--cwd", $this->dir, 'push', $this->repo->repopath);
+
+    list($proc, $pipes) = mtrack_run_tool('hg', 'proc', $a);
+
+    $out = stream_get_contents($pipes[1]);
+    $err = stream_get_contents($pipes[2]);
+    $st = proc_close($proc);
+
+    if ($st) {
+      throw new Exception("push failed with status $st: $err $out");
+    }
+    mtrack_rmdir($this->dir);
+  }
+
+  function getFile($path)
+  {
+    return $this->repo->file($path);
+  }
+
+  function addFile($path)
+  {
+    // nothing to do; we use --addremove
+  }
+
+  function delFile($path)
+  {
+    // we use --addremove when we commit for this to take effect
+    unlink($this->dir . DIRECTORY_SEPARATOR . $path);
+  }
+
+  function commit(MTrackChangeset $CS)
+  {
+    $hg_date = (int)strtotime($CS->when) . ' 0';
+    $reason = trim($CS->reason);
+    if (!strlen($reason)) {
+      $reason = 'Changed';
+    }
+    $out = $this->hg('ci', '--addremove',
+      '-m', $reason,
+      '-d', $hg_date,
+      '-u', $CS->who);
+    $data = stream_get_contents($out);
+    $st = pclose($out);
+    if ($st != 0) {
+      throw new Exception("commit failed $st $data");
+    }
+  }
+
+  function hg()
+  {
+    $args = func_get_args();
+    $a = array("-y", "--cwd", $this->dir);
+    foreach ($args as $arg) {
+      $a[] = $arg;
+    }
+
+    return mtrack_run_tool('hg', 'read', $a);
+  }
+}
+
+class MTrackSCMHg extends MTrackRepo {
+  protected $hg = 'hg';
+  protected $branches = null;
+  protected $tags = null;
+
+  public function getSCMMetaData() {
+    return array(
+      'name' => 'Mercurial',
+      'tools' => array('hg'),
+    );
+  }
+
+  public function reconcileRepoSettings(MTrackSCM $r = null) {
+    if ($r == null) {
+      $r = $this;
+    }
+    $description = substr(preg_replace("/\r?\n/m", ' ', $r->description), 0, 64);
+    $description = trim($description);
+    if (!is_dir($r->repopath)) {
+      if ($r->clonedfrom) {
+        $S = MTrackRepo::loadById($r->clonedfrom);
+        $stm = mtrack_run_tool('hg', 'read', array(
+          'clone', $S->repopath, $r->repopath));
+      } else {
+        $stm = mtrack_run_tool('hg', 'read', array('init', $r->repopath));
+      }
+      $out = stream_get_contents($stm);
+      $st = pclose($stm);
+      if ($st) {
+        throw new Exception("hg: failed $out");
+      }
+    }
+
+    $php = MTrackConfig::get('tools', 'php');
+    $conffile = realpath(MTrackConfig::getLocation());
+
+    $install = realpath(dirname(__FILE__) . '/../../');
+
+    /* fixup config */
+    $apply = array(
+      "hooks" => array(
+        "changegroup.mtrack" =>
+          "$php $install/bin/hg-commit-hook changegroup $conffile",
+        "commit.mtrack" =>
+          "$php $install/bin/hg-commit-hook commit $conffile",
+        "pretxncommit.mtrack" =>
+          "$php $install/bin/hg-commit-hook pretxncommit $conffile",
+        "pretxnchangegroup.mtrack" =>
+          "$php $install/bin/hg-commit-hook pretxnchangegroup $conffile",
+      ),
+      "web" => array(
+        "description" => $description,
+      )
+    );
+
+    $cfg = @file_get_contents("$r->repopath/.hg/hgrc");
+    $adds = array();
+
+    foreach ($apply as $sect => $opts) {
+      foreach ($opts as $name => $value) {
+        if (preg_match("/^$name\s*=/m", $cfg)) {
+          $cfg = preg_replace("/^$name\s*=.*$/m", "$name = $value", $cfg);
+        } else {
+          $adds[$sect][$name] = $value;
+        }
+      }
+    }
+
+    foreach ($adds as $sect => $opts) {
+      $cfg .= "[$sect]\n";
+      foreach ($opts as $name => $value) {
+        $cfg .= "$name = $value\n";
+      }
+    }
+    file_put_contents("$r->repopath/.hg/hgrc", $cfg, LOCK_EX);
+    system("chmod -R 02777 $r->repopath");
+  }
+
+  function canFork() {
+    return true;
+  }
+
+  function getServerURL() {
+    $url = parent::getServerURL();
+    if ($url) return $url;
+    $url = MTrackConfig::get('repos', 'serverurl');
+    if ($url) {
+      return "ssh://$url/" . $this->getBrowseRootName();
+    }
+    return null;
+  }
+
+  public function getBranches()
+  {
+    if ($this->branches !== null) {
+      return $this->branches;
+    }
+    $this->branches = array();
+    $fp = $this->hg('branches');
+    while ($line = fgets($fp)) {
+      list($branch, $revstr) = preg_split('/\s+/', $line);
+      list($num, $rev) = explode(':', $revstr, 2);
+      $this->branches[$branch] = $rev;
+    }
+    $fp = null;
+    return $this->branches;
+  }
+
+  public function getTags()
+  {
+    if ($this->tags !== null) {
+      return $this->tags;
+    }
+    $this->tags = array();
+    $fp = $this->hg('tags');
+    while ($line = fgets($fp)) {
+      list($tag, $revstr) = preg_split('/\s+/', $line);
+      list($num, $rev) = explode(':', $revstr, 2);
+      $this->tags[$tag] = $rev;
+    }
+    $fp = null;
+    return $this->tags;
+  }
+
+  public function readdir($path, $object = null, $ident = null)
+  {
+    $res = array();
+
+    if ($object === null) {
+      $object = 'branch';
+      $ident = 'default';
+    }
+    $rev = $this->resolveRevision(null, $object, $ident);
+
+    $fp = $this->hg('manifest', '-r', $rev);
+
+    if (strlen($path)) {
+      $path .= '/';
+    }
+    $plen = strlen($path);
+
+    $dirs = array();
+    $exists = false;
+
+    while ($line = fgets($fp)) {
+      $name = trim($line);
+
+      if (!strncmp($name, $path, $plen)) {
+        $exists = true;
+        $ent = substr($name, $plen);
+        if (strpos($ent, '/') === false) {
+          $res[] = new MTrackSCMFileHg($this, "$path$ent", $rev);
+        } else {
+          list($d) = explode('/', $ent, 2);
+          if (!isset($dirs[$d])) {
+            $dirs[$d] = $d;
+            $res[] = new MTrackSCMFileHg($this, "$path$d", $rev, true);
+          }
+        }
+      }
+    }
+
+    if (!$exists) {
+      throw new Exception("location $path does not exist");
+    }
+    return $res;
+  }
+
+  public function file($path, $object = null, $ident = null)
+  {
+    if ($object == null) {
+      $branches = $this->getBranches();
+      if (isset($branches['default'])) {
+        $object = 'branch';
+        $ident = 'default';
+      } else {
+        // fresh/empty repo
+        $object = 'tag';
+        $ident = 'tip';
+      }
+    }
+    $rev = $this->resolveRevision(null, $object, $ident);
+    return new MTrackSCMFileHg($this, $path, $rev);
+  }
+
+  public function history($path, $limit = null, $object = null, $ident = null)
+  {
+    $res = array();
+
+    $args = array();
+    if ($object !== null) {
+      $rev = $this->resolveRevision(null, $object, $ident);
+      $args[] = '-r';
+      $args[] = $rev;
+    }
+    if ($limit !== null) {
+      if (is_int($limit)) {
+        $args[] = '-l';
+        $args[] = $limit;
+      } else {
+        $t = strtotime($limit);
+        $args[] = '-d';
+        $args[] = ">$t 0";
+      }
+    }
+
+    $sep = uniqid();
+    $fp = $this->hg('log',
+      '--template', $sep . '\n{node|short}\n{branches}\n{tags}\n{file_adds}\n{file_copies}\n{file_mods}\n{file_dels}\n{author|email}\n{date|hgdate}\n{desc}\n', $args,
+      $path);
+
+    fgets($fp); # discard leading $sep
+
+    // corresponds to the file_adds, file_copies, file_modes, file_dels
+    // in the template above
+    static $file_status_order = array('A', 'C', 'M', 'D');
+
+    while (true) {
+      $ent = new MTrackSCMEvent;
+      $ent->rev = trim(fgets($fp));
+      if (!strlen($ent->rev)) {
+        break;
+      }
+
+      $ent->branches = array();
+      foreach (preg_split('/\s+/', trim(fgets($fp))) as $b) {
+        if (strlen($b)) {
+          $ent->branches[] = $b;
+        }
+      }
+      if (!count($ent->branches)) {
+        $ent->branches[] = 'default';
+      }
+
+      $ent->tags = array();
+      foreach (preg_split('/\s+/', trim(fgets($fp))) as $t) {
+        if (strlen($t)) {
+          $ent->tags[] = $t;
+        }
+      }
+
+      $ent->files = array();
+
+      foreach ($file_status_order as $status) {
+        foreach (preg_split('/\s+/', trim(fgets($fp))) as $t) {
+          if (strlen($t)) {
+            $f = new MTrackSCMFileEvent;
+            $f->name = $t;
+            $f->status = $status;
+            $ent->files[] = $f;
+          }
+        }
+      }
+
+      $ent->changeby = trim(fgets($fp));
+      list($ts) = preg_split('/\s+/', fgets($fp));
+      $ent->ctime = MTrackDB::unixtime((int)$ts);
+      $changelog = array();
+      while (($line = fgets($fp)) !== false) {
+        $line = rtrim($line, "\r\n");
+        if ($line == $sep) {
+          break;
+        }
+        $changelog[] = $line;
+      }
+      $ent->changelog = join("\n", $changelog);
+
+      $res[] = $ent;
+
+      if ($line === false) {
+        break;
+      }
+    }
+    $fp = null;
+    return $res;
+  }
+
+  public function diff($path, $from = null, $to = null)
+  {
+    if ($path instanceof MTrackSCMFile) {
+      if ($from === null) {
+        $from = $path->rev;
+      }
+      $path = $path->name;
+    }
+    if ($to !== null) {
+      return $this->hg('diff', '-r', $from, '-r', $to,
+        '--git', $path);
+    }
+    return $this->hg('diff', '-c', $from, '--git', $path);
+  }
+
+  public function getWorkingCopy()
+  {
+    return new MTrackWCHg($this);
+  }
+
+  public function getRelatedChanges($revision)
+  {
+    $parents = array();
+    $kids = array();
+
+    foreach (preg_split('/\s+/',
+          stream_get_contents($this->hg('parents', '-r', $revision,
+              '--template', '{node|short}\n'))) as $p) {
+      if (strlen($p)) {
+        $parents[] = $p;
+      }
+    }
+
+    foreach (preg_split('/\s+/',
+        stream_get_contents($this->hg('--config',
+          'extensions.children=',
+          'children', '-r', $revision,
+          '--template', '{node|short}\n'))) as $p) {
+      if (strlen($p)) {
+        $kids[] = $p;
+      }
+    }
+    return array($parents, $kids);
+  }
+
+  function hg()
+  {
+    $args = func_get_args();
+    $a = array("-y", "-R", $this->repopath, "--cwd", $this->repopath);
+    foreach ($args as $arg) {
+      $a[] = $arg;
+    }
+
+    return mtrack_run_tool('hg', 'read', $a);
+  }
+}
+
+MTrackRepo::registerSCM('hg', 'MTrackSCMHg');
+
diff --git a/inc/scm/svn.php b/inc/scm/svn.php
new file mode 100644 (file)
index 0000000..78536f1
--- /dev/null
@@ -0,0 +1,466 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+/* Subversion SVN browsing */
+
+class MTrackSCMFileSVN extends MTrackSCMFile {
+  public $name;
+  public $rev;
+  public $is_dir;
+  public $repo;
+
+  function __construct(MTrackSCM $repo, $name, $rev, $is_dir = false)
+  {
+    $this->repo = $repo;
+    $this->name = $name;
+    $this->rev = $rev;
+    $this->is_dir = $is_dir;
+  }
+
+  public function _determineFileChangeEvent($reponame, $filename, $rev)
+  {
+    $repo = MTrackRepo::loadByName($reponame);
+    list($ent) = $repo->history($filename, 1, 'rev', $rev);
+    return $ent;
+  }
+
+  public function getChangeEvent()
+  {
+    return mtrack_cache(
+      array('MTrackSCMFileSVN', '_determineFileChangeEvent'),
+      array($this->repo->getBrowseRootName(), $this->name, $this->rev),
+      864000);
+  }
+
+  function cat()
+  {
+    return $this->repo->svn('cat', '-r', $this->rev,
+      'file://' . $this->repo->repopath . '/' . $this->name . "@$this->rev");
+  }
+
+  function annotate($include_line_content = false)
+  {
+    $xml = stream_get_contents($this->repo->svn('annotate', '--xml',
+      'file://' . $this->repo->repopath . '/' . $this->name . "@$this->rev"));
+    $ann = array();
+    $xml = @simplexml_load_string($xml);
+    if (!is_object($xml)) {
+      return 'DELETED';
+    }
+    if ($include_line_content) {
+      $cat = $this->cat();
+    }
+    foreach ($xml->target->entry as $ent) {
+      $A = new MTrackSCMAnnotation;
+      $A->rev = (int)$ent->commit['revision'];
+      $A->changeby = (string)$ent->commit->author;
+      if ($include_line_content) {
+        $A->line = fgets($cat);
+      }
+      $ann[(int)$ent['line-number']] = $A;
+    }
+    return $ann;
+
+  }
+}
+
+class MTrackWCSVN extends MTrackSCMWorkingCopy {
+  public $repo;
+
+  function __construct(MTrackRepo $repo) {
+    $this->dir = mtrack_make_temp_dir();
+    $this->repo = $repo;
+
+    stream_get_contents($this->repo->svn('checkout',
+      'file://' . $this->repo->repopath . '/trunk',
+      $this->dir));
+  }
+
+  function getFile($path)
+  {
+    return $this->repo->file('trunk/' . $path);
+  }
+
+
+  function addFile($path)
+  {
+    stream_get_contents(
+      $this->repo->svn('add', $this->dir . DIRECTORY_SEPARATOR . $path));
+  }
+
+  function delFile($path)
+  {
+    stream_get_contents(
+      $this->repo->svn('rm', $this->dir . DIRECTORY_SEPARATOR . $path));
+  }
+
+  function commit(MTrackChangeset $CS)
+  {
+    list($proc, $pipes) = mtrack_run_tool('svn', 'proc',
+      array('ci', '--non-interactive', '--username', $CS->who,
+        '-m', $CS->reason, $this->dir));
+/*
+    $svn = MTrackConfig::get('tools', 'svn');
+    if (!strlen($svn)) $svn = 'svn';
+    $proc = proc_open(
+      "$svn ci --non-interactive " .
+      ' --username ' . escapeshellarg($CS->who) .
+      ' -m ' . escapeshellarg($CS->reason) .
+      ' ' . $this->dir,
+      array(
+        0 => array('pipe', 'r'),
+        1 => array('pipe', 'w'),
+        2 => array('pipe', 'w'),
+      ), $pipes, $this->dir);
+*/
+    $pipes[0] = null;
+    $output = stream_get_contents($pipes[1]);
+    $err = stream_get_contents($pipes[2]);
+
+    if (strlen($err)) {
+      throw new Exception($err);
+    }
+
+    if (preg_match("/Committed revision (\d+)/", $output, $M)) {
+      $rev = $M[1];
+      stream_get_contents(
+          $this->repo->svn('propset', 'svn:date',
+            '--revprop',
+            '-r', $rev, $CS->when, $this->dir
+            ));
+    }
+  }
+}
+
+class MTrackSCMSVN extends MTrackRepo {
+  protected $svn = 'svn';
+  static $debug = false;
+
+  public function getSCMMetaData() {
+    return array(
+      'name' => 'Subversion',
+      'tools' => array('svn', 'svnlook', 'svnadmin'),
+    );
+  }
+
+  function getServerURL() {
+    $url = parent::getServerURL();
+    if ($url) return $url;
+    $url = MTrackConfig::get('repos', 'serverurl');
+    if ($url) {
+      return "svn+ssh://$url/" . $this->getBrowseRootName() . '/BRANCHNAME';
+    }
+    return null;
+  }
+
+
+  public function reconcileRepoSettings(MTrackSCM $r = null) {
+    if ($r == null) {
+      $r = $this;
+    }
+    if (!is_dir($r->repopath)) {
+      $stm = mtrack_run_tool('svnadmin', 'read', array('create', $r->repopath));
+      $out = stream_get_contents($stm);
+      if (pclose($stm)) {
+        throw new Exception("failed to create repo: $out");
+      }
+      file_put_contents("$r->repopath/hooks/pre-revprop-change",
+          "#!/bin/sh\nexit 0\n");
+      chmod("$r->repopath/hooks/pre-revprop-change", 0755);
+      $me = mtrack_canon_username(MTrackAuth::whoami());
+      $stm = mtrack_run_tool('svn', 'read', array('mkdir', '-m', 'init',
+        '--username', $me, "file://$r->repopath/trunk"));
+      $out = stream_get_contents($stm);
+      if (pclose($stm)) {
+        throw new Exception("failed to create trunk: $out");
+      }
+      system("chmod -R 02777 $r->repopath/db $r->repopath/locks");
+
+      $authzname = MTrackConfig::get('core', 'vardir') . '/svn.authz';
+      $svnserve = "[general]\nauthz-db = $authzname\n";
+      file_put_contents("$r->repopath/conf/svnserve.conf", $svnserve);
+    }
+  }
+
+  public function getDefaultRoot() {
+    return 'trunk/';
+  }
+
+  public function getBranches()
+  {
+    return null;
+  }
+
+  public function getTags()
+  {
+    return null;
+  }
+
+  public function readdir($path, $object = null, $ident = null)
+  {
+    $res = array();
+
+    if ($object === null) {
+      $object = 'rev';
+      $ident = 'HEAD';
+    }
+    $rev = $this->resolveRevision(null, $object, $ident);
+
+    $rpath = $this->repopath;
+    if (strlen($path)) {
+      $rpath .= "/$path";
+    }
+
+    $fp = $this->svn('ls', '--xml', '-r', $rev,
+          "file://" . $rpath);
+
+    $ls = stream_get_contents($fp);
+    $doc = simplexml_load_string($ls);
+    if (!is_object($doc)) {
+      echo '<pre>', htmlentities($ls, ENT_QUOTES, 'utf-8'), '</pre>';
+    }
+    if (isset($doc->list)) foreach ($doc->list->entry as $le) {
+      $name = $path;
+      $name .= '/';
+      $name .= $le->name;
+      if ($name[0] == '/') {
+        $name = substr($name, 1);
+      }
+      /* Use the revision passed in to readdir rather than the revision
+       * in the entry, as svn can return a revision number that pre-dates
+       * that of the containing tag, and this causes the subsequent
+       * lookup of commit data to fail */
+      $res[] = new MTrackSCMFileSVN($this, $name,
+          //$le->commit['revision'],
+          $rev,
+          $le['kind'] == 'dir');
+    }
+    return $res;
+  }
+
+  public function file($path, $object = null, $ident = null)
+  {
+    if ($object == null) {
+      $object = 'rev';
+      $ident = 'HEAD';
+    }
+    $rev = $this->resolveRevision(null, $object, $ident);
+    return new MTrackSCMFileSVN($this, $path, $rev);
+  }
+
+  public function history($path, $limit = null, $object = null, $ident = null)
+  {
+    $res = array();
+    $args = array();
+    $limit_date = null;
+
+    if ($limit !== null) {
+      if (!is_int($limit)) {
+        $limit_date = strtotime($limit);
+        $limit = null;
+        $limit_date = date('c', $limit_date);
+      }
+    }
+
+    $use_at_rev = false;
+    if ($object !== null) {
+      $rev = $this->resolveRevision(null, $object, $ident);
+      if ($limit_date != null) {
+        $args[] = '-r';
+        $args[] = $rev . ':{' . $limit_date . '}';
+      } else if ($rev == 'HEAD') {
+        $args[] = '-r';
+        $args[] = "$rev:1";
+      } else {
+        $use_at_rev = true;
+      }
+    }
+    if ($limit !== null) {
+      $args[] = '--limit';
+      $args[] = $limit;
+    } else if ($limit_date !== null) {
+      $args[] = '-r';
+      $args[] = '{' . $limit_date . '}:head';
+    }
+
+    $rpath = $this->repopath;
+    if (strlen($path)) {
+      if ($path[0] != '/') {
+        $rpath .= '/';
+      }
+      $rpath .= $path;
+    }
+    $spath = $rpath;
+
+    if ($use_at_rev) {
+      $spath .= "@$rev";
+    }
+
+    $fp = $this->svn('log', '--xml', '-v', $args, "file://$spath");
+
+    $xml = stream_get_contents($fp);
+    $doc = @simplexml_load_string($xml);
+    if (!is_object($doc)) {
+      /* try looking at the parent */
+      $spath = dirname($spath);
+      if ($use_at_rev) {
+        $spath .= "@$rev";
+      }
+      $fp = $this->svn('log', '--xml', '-v', $args, "file://$spath");
+      $xml = stream_get_contents($fp);
+      $doc = @simplexml_load_string($xml);
+    }
+
+    if (!is_object($doc)) {
+//      echo '<pre>', htmlentities($xml, ENT_QUOTES, 'utf-8'), '</pre>';
+      return null;
+    }
+    if (self::$debug) {
+      if (php_sapi_name() == 'cli') {
+        echo $xml, "\n";
+      } else {
+        echo htmlentities(var_export($xml, true)) . "<br>";
+      }
+    }
+    $origpath = $path;
+    if ($origpath[0] != '/') {
+      $origpath = '/' . $origpath;
+    }
+    if ($doc->logentry) foreach ($doc->logentry as $le) {
+      $matched = false;
+      $ent = new MTrackSCMEvent;
+      $ent->rev = (int)$le['revision'];
+      $ent->branches = array();
+      $ent->tags = array();
+
+      $ent->files = array();
+      foreach ($le->paths->path as $path) {
+        if (strncmp($path, $origpath, strlen($origpath))) {
+          continue;
+        }
+        $matched = true;
+        $f = new MTrackSCMFileEvent;
+        $f->name = (string)$path;
+        $f->status = (string)$path['action'];
+        $ent->files[] = $f;
+      }
+
+      if ($matched) {
+        $ent->changeby = (string)$le->author;
+        $ent->ctime = MTrackDB::unixtime(strtotime($le->date));
+        $ent->changelog = (string)$le->msg;
+
+        $res[] = $ent;
+      }
+    }
+    $fp = null;
+    if (count($res) == 0) {
+      return null;
+    }
+    return $res;
+  }
+
+  function getCheckoutCommand() {
+    $url = $this->getServerURL();
+    if (strlen($url)) {
+      return $this->scmtype . ' checkout ' . $this->getServerURL();
+    }
+    return null;
+  }
+
+  public function diff($path, $from = null, $to = null)
+  {
+    $is_file = null;
+
+    if ($path instanceof MTrackSCMFile) {
+      $is_file = !$path->is_dir;
+      if ($from === null) {
+        $from = $path->rev;
+      }
+      $path = $path->name;
+    } elseif ($path instanceof MTrackSCMFileEvent) {
+      $is_file = true;
+    } else {
+      // http://subversion.tigris.org/issues/show_bug.cgi?id=2873
+      // Essentially, if there are files added in a changeset, you cannot use
+      // diff to show the diff of those newly added files if you explicitly
+      // request the file itself.  So we need to assess whether $path represents
+      // a file and dance around by diffing the parent path.
+
+      $is_file = false;
+      $info = $this->svn('info', "file://$this->repopath$path", '-r', $from);
+      $lines = 0;
+      while (($line = fgets($info)) !== false) {
+        $lines++;
+        if (preg_match("/^Node Kind:\s+file/", $line)) {
+          $is_file = true;
+          break;
+        }
+      }
+      if ($lines == 0) {
+        // no data returned; path doesn't exist at that revision
+        if ($to === null) {
+          $to = $from;
+          $from--;
+        }
+      }
+    }
+    if ($is_file) {
+      $diffpath = dirname($path);
+    } else {
+      $diffpath = $path;
+    }
+
+    if ($to !== null) {
+      $diff = $this->svn('diff', '-r', $from, '-r', $to,
+        "file://$this->repopath$diffpath");
+    } else {
+      $diff = $this->svn('diff', '-c', $from,
+        "file://$this->repopath$diffpath");
+    }
+
+    if ($is_file) {
+      $dir = $diff;
+      $diff = tmpfile();
+      $wanted = basename($path);
+      $in_wanted = false;
+      // search in the diffstream for the file that was originally requested
+      // and copy that through to the tmpfile we're using for the diff we're
+      // returning to the caller
+      while (($line = fgets($dir)) !== false) {
+        if (preg_match("/^Index: $wanted$/", $line)) {
+          $in_wanted = true;
+          fwrite($diff, $line);
+          continue;
+        } else if (preg_match("/^Index: /", $line)) {
+          if ($in_wanted) {
+            break;
+          }
+        }
+        if ($in_wanted) {
+          fwrite($diff, $line);
+        }
+      }
+      fseek($diff, 0);
+    }
+    return $diff;
+  }
+
+  public function getWorkingCopy()
+  {
+    return new MTrackWCSVN($this);
+  }
+
+  public function getRelatedChanges($revision)
+  {
+    return null;
+  }
+
+  function svn()
+  {
+    $args = func_get_args();
+    return mtrack_run_tool('svn', 'read', $args);
+  }
+}
+
+MTrackRepo::registerSCM('svn', 'MTrackSCMSVN');
diff --git a/inc/search.php b/inc/search.php
new file mode 100644 (file)
index 0000000..60be534
--- /dev/null
@@ -0,0 +1,92 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include MTRACK_INC_DIR . '/search/lucene.php';
+include MTRACK_INC_DIR . '/search/solr.php';
+
+class MTrackSearchResult {
+  /** object identifier of result */
+  public $objectid;
+  /** result ranking; higher is more relevant */
+  public $score;
+  /** excerpt of matching text */
+  public $excerpt;
+
+  /* some implementations may need the caller to provide the context
+   * text; the default just returns what is there */
+  function getExcerpt($text) {
+    return $this->excerpt;
+  }
+}
+
+interface IMTrackSearchEngine {
+  public function setBatchMode();
+  public function commit($optimize = false);
+  public function add($object, $fields, $replace = false);
+  /** returns an array of MTrackSearchResult objects corresponding
+   * to matches to the supplied query string */
+  public function search($query);
+}
+
+class MTrackSearchDB {
+  static $index = null;
+  static $engine = null;
+
+  static function getEngine() {
+    if (self::$engine === null) {
+      $name = MTrackConfig::get('core', 'search_engine');
+      if (!$name) $name = 'MTrackSearchEngineLucene';
+      self::$engine = new $name;
+    }
+    return self::$engine;
+  }
+
+  /* functions that can perform indexing */
+  static $funcs = array();
+
+  static function register_indexer($id, $func)
+  {
+    self::$funcs[$id] = $func;
+  }
+
+  static function index_object($id)
+  {
+    $key = $id;
+    while (strlen($key)) {
+      if (isset(self::$funcs[$key])) {
+        break;
+      }
+      $new_key = preg_replace('/:[^:]+$/', '', $key);
+      if ($key == $new_key) {
+        break;
+      }
+      $key = $new_key;
+    }
+
+    if (isset(self::$funcs[$key])) {
+      $func = self::$funcs[$key];
+      return call_user_func($func, $id);
+    }
+    return false;
+  }
+
+  static function get() {
+    return self::getEngine()->getIdx();
+  }
+
+  static function setBatchMode() {
+    self::getEngine()->setBatchMode();
+  }
+
+  static function commit($optimize = false) {
+    self::getEngine()->commit($optimize);
+  }
+
+  static function add($object, $fields, $replace = false) {
+    self::getEngine()->add($object, $fields, $replace);
+  }
+
+  static function search($query) {
+    return self::getEngine()->search($query);
+  }
+}
diff --git a/inc/search/lucene.php b/inc/search/lucene.php
new file mode 100644 (file)
index 0000000..cd40bc5
--- /dev/null
@@ -0,0 +1,980 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+require_once 'Zend/Search/Lucene.php';
+
+/**
+ * Copyright (c) 2005 Richard Heyes (http://www.phpguru.org/)
+ * PHP5 Implementation of the Porter Stemmer algorithm. Certain elements
+ * were borrowed from the (broken) implementation by Jon Abernathy.
+ */
+class PorterStemmer {
+  /**
+   * Regex for matching a consonant
+   * @var string
+   */
+  private static $regex_consonant =
+    '(?:[bcdfghjklmnpqrstvwxz]|(?<=[aeiou])y|^y)';
+
+  /**
+   * Regex for matching a vowel
+   * @var string
+   */
+  private static $regex_vowel = '(?:[aeiou]|(?<![aeiou])y)';
+
+  /**
+   * Stems a word. Simple huh?
+   *
+   * @param  string $word Word to stem
+   * @return string       Stemmed word
+   */
+  public static function Stem($word)
+  {
+    if (strlen($word) <= 2) {
+      return $word;
+    }
+
+    $word = self::step1ab($word);
+    $word = self::step1c($word);
+    $word = self::step2($word);
+    $word = self::step3($word);
+    $word = self::step4($word);
+    $word = self::step5($word);
+
+    return $word;
+  }
+
+  /**
+   * Step 1
+   */
+  private static function step1ab($word)
+  {
+    // Part a
+    if (substr($word, -1) == 's') {
+
+      self::replace($word, 'sses', 'ss')
+        OR self::replace($word, 'ies', 'i')
+        OR self::replace($word, 'ss', 'ss')
+        OR self::replace($word, 's', '');
+    }
+
+    // Part b
+    if (substr($word, -2, 1) != 'e' OR !self::replace($word, 'eed', 'ee', 0)) { // First rule
+      $v = self::$regex_vowel;
+
+      // ing and ed
+      if (   preg_match("#$v+#", substr($word, 0, -3)) && self::replace($word, 'ing', '')
+          OR preg_match("#$v+#", substr($word, 0, -2)) && self::replace($word, 'ed', '')) { // Note use of && and OR, for precedence reasons
+
+        // If one of above two test successful
+        if (    !self::replace($word, 'at', 'ate')
+            AND !self::replace($word, 'bl', 'ble')
+            AND !self::replace($word, 'iz', 'ize')) {
+
+          // Double consonant ending
+          if (    self::doubleConsonant($word)
+              AND substr($word, -2) != 'll'
+              AND substr($word, -2) != 'ss'
+              AND substr($word, -2) != 'zz') {
+
+            $word = substr($word, 0, -1);
+
+          } else if (self::m($word) == 1 AND self::cvc($word)) {
+            $word .= 'e';
+          }
+        }
+      }
+    }
+
+    return $word;
+  }
+
+  /**
+   * Step 1c
+   *
+   * @param string $word Word to stem
+   */
+  private static function step1c($word)
+  {
+    $v = self::$regex_vowel;
+
+    if (substr($word, -1) == 'y' && preg_match("#$v+#", substr($word, 0, -1))) {
+      self::replace($word, 'y', 'i');
+    }
+
+    return $word;
+  }
+
+  /**
+   * Step 2
+   *
+   * @param string $word Word to stem
+   */
+  private static function step2($word)
+  {
+    switch (substr($word, -2, 1)) {
+      case 'a':
+        self::replace($word, 'ational', 'ate', 0)
+          OR self::replace($word, 'tional', 'tion', 0);
+        break;
+
+      case 'c':
+        self::replace($word, 'enci', 'ence', 0)
+          OR self::replace($word, 'anci', 'ance', 0);
+        break;
+
+      case 'e':
+        self::replace($word, 'izer', 'ize', 0);
+        break;
+
+      case 'g':
+        self::replace($word, 'logi', 'log', 0);
+        break;
+
+      case 'l':
+        self::replace($word, 'entli', 'ent', 0)
+          OR self::replace($word, 'ousli', 'ous', 0)
+          OR self::replace($word, 'alli', 'al', 0)
+          OR self::replace($word, 'bli', 'ble', 0)
+          OR self::replace($word, 'eli', 'e', 0);
+        break;
+
+      case 'o':
+        self::replace($word, 'ization', 'ize', 0)
+          OR self::replace($word, 'ation', 'ate', 0)
+          OR self::replace($word, 'ator', 'ate', 0);
+        break;
+
+      case 's':
+        self::replace($word, 'iveness', 'ive', 0)
+          OR self::replace($word, 'fulness', 'ful', 0)
+          OR self::replace($word, 'ousness', 'ous', 0)
+          OR self::replace($word, 'alism', 'al', 0);
+        break;
+
+      case 't':
+        self::replace($word, 'biliti', 'ble', 0)
+          OR self::replace($word, 'aliti', 'al', 0)
+          OR self::replace($word, 'iviti', 'ive', 0);
+        break;
+    }
+
+    return $word;
+  }
+
+  /**
+   * Step 3
+   *
+   * @param string $word String to stem
+   */
+  private static function step3($word)
+  {
+    switch (substr($word, -2, 1)) {
+      case 'a':
+        self::replace($word, 'ical', 'ic', 0);
+        break;
+
+      case 's':
+        self::replace($word, 'ness', '', 0);
+        break;
+
+      case 't':
+        self::replace($word, 'icate', 'ic', 0)
+          OR self::replace($word, 'iciti', 'ic', 0);
+        break;
+
+      case 'u':
+        self::replace($word, 'ful', '', 0);
+        break;
+
+      case 'v':
+        self::replace($word, 'ative', '', 0);
+        break;
+
+      case 'z':
+        self::replace($word, 'alize', 'al', 0);
+        break;
+    }
+
+    return $word;
+  }
+
+  /**
+   * Step 4
+   *
+   * @param string $word Word to stem
+   */
+  private static function step4($word)
+  {
+    switch (substr($word, -2, 1)) {
+      case 'a':
+        self::replace($word, 'al', '', 1);
+        break;
+
+      case 'c':
+        self::replace($word, 'ance', '', 1)
+          OR self::replace($word, 'ence', '', 1);
+        break;
+
+      case 'e':
+        self::replace($word, 'er', '', 1);
+        break;
+
+      case 'i':
+        self::replace($word, 'ic', '', 1);
+        break;
+
+      case 'l':
+        self::replace($word, 'able', '', 1)
+          OR self::replace($word, 'ible', '', 1);
+        break;
+
+      case 'n':
+        self::replace($word, 'ant', '', 1)
+          OR self::replace($word, 'ement', '', 1)
+          OR self::replace($word, 'ment', '', 1)
+          OR self::replace($word, 'ent', '', 1);
+        break;
+
+      case 'o':
+        if (substr($word, -4) == 'tion' OR substr($word, -4) == 'sion') {
+          self::replace($word, 'ion', '', 1);
+        } else {
+          self::replace($word, 'ou', '', 1);
+        }
+        break;
+
+      case 's':
+        self::replace($word, 'ism', '', 1);
+        break;
+
+      case 't':
+        self::replace($word, 'ate', '', 1)
+          OR self::replace($word, 'iti', '', 1);
+        break;
+
+      case 'u':
+        self::replace($word, 'ous', '', 1);
+        break;
+
+      case 'v':
+        self::replace($word, 'ive', '', 1);
+        break;
+
+      case 'z':
+        self::replace($word, 'ize', '', 1);
+        break;
+    }
+
+    return $word;
+  }
+
+  /**
+   * Step 5
+   *
+   * @param string $word Word to stem
+   */
+  private static function step5($word)
+  {
+    // Part a
+    if (substr($word, -1) == 'e') {
+      if (self::m(substr($word, 0, -1)) > 1) {
+        self::replace($word, 'e', '');
+
+      } else if (self::m(substr($word, 0, -1)) == 1) {
+
+        if (!self::cvc(substr($word, 0, -1))) {
+          self::replace($word, 'e', '');
+        }
+      }
+    }
+
+    // Part b
+    if (self::m($word) > 1 AND
+        self::doubleConsonant($word) AND substr($word, -1) == 'l') {
+      $word = substr($word, 0, -1);
+    }
+
+    return $word;
+  }
+
+  /**
+   * Replaces the first string with the second, at the end of the string. If third
+   * arg is given, then the preceding string must match that m count at least.
+   *
+   * @param  string $str   String to check
+   * @param  string $check Ending to check for
+   * @param  string $repl  Replacement string
+   * @param  int    $m     Optional minimum number of m() to meet
+   * @return bool          Whether the $check string was at the end
+   *                       of the $str string. True does not necessarily mean
+   *                       that it was replaced.
+   */
+  private static function replace(&$str, $check, $repl, $m = null)
+  {
+    $len = 0 - strlen($check);
+
+    if (substr($str, $len) == $check) {
+      $substr = substr($str, 0, $len);
+      if (is_null($m) OR self::m($substr) > $m) {
+        $str = $substr . $repl;
+      }
+
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * What, you mean it's not obvious from the name?
+   *
+   * m() measures the number of consonant sequences in $str. if c is
+   * a consonant sequence and v a vowel sequence, and <..> indicates arbitrary
+   * presence,
+   *
+   * <c><v>       gives 0
+   * <c>vc<v>     gives 1
+   * <c>vcvc<v>   gives 2
+   * <c>vcvcvc<v> gives 3
+   *
+   * @param  string $str The string to return the m count for
+   * @return int         The m count
+   */
+  private static function m($str)
+  {
+    $c = self::$regex_consonant;
+    $v = self::$regex_vowel;
+
+    $str = preg_replace("#^$c+#", '', $str);
+    $str = preg_replace("#$v+$#", '', $str);
+
+    preg_match_all("#($v+$c+)#", $str, $matches);
+
+    return count($matches[1]);
+  }
+
+
+  /**
+   * Returns true/false as to whether the given string contains two
+   * of the same consonant next to each other at the end of the string.
+   *
+   * @param  string $str String to check
+   * @return bool        Result
+   */
+  private static function doubleConsonant($str)
+  {
+    $c = self::$regex_consonant;
+
+    return preg_match("#$c{2}$#", $str, $matches)
+      AND $matches[0]{0} == $matches[0]{1};
+  }
+
+
+  /**
+   * Checks for ending CVC sequence where second C is not W, X or Y
+   *
+   * @param  string $str String to check
+   * @return bool        Result
+   */
+  private static function cvc($str)
+  {
+    $c = self::$regex_consonant;
+    $v = self::$regex_vowel;
+
+    return     preg_match("#($c$v$c)$#", $str, $matches)
+      AND strlen($matches[1]) == 3
+      AND $matches[1]{2} != 'w'
+      AND $matches[1]{2} != 'x'
+      AND $matches[1]{2} != 'y';
+  }
+}
+
+class MTrackSearchStemmer extends
+    Zend_Search_Lucene_Analysis_TokenFilter {
+
+  public function normalize(Zend_Search_Lucene_Analysis_Token $tok)
+  {
+    $text = $tok->getTermText();
+    $text = PorterStemmer::Stem($text);
+    $ntok = new Zend_Search_Lucene_Analysis_Token($text,
+                  $tok->getStartOffset(),
+                  $tok->getEndOffset());
+    $ntok->setPositionIncrement($tok->getPositionIncrement());
+    return $tok;
+  }
+}
+
+class MTrackSearchDateToken extends Zend_Search_Lucene_Analysis_Token {
+}
+
+class MTrackSearchAnalyzer extends Zend_Search_Lucene_Analysis_Analyzer_Common
+{
+  private $_position;
+  private $_bytePosition;
+  private $_moreTokens = array();
+
+  function reset()
+  {
+    $this->_position = 0;
+    $this->_bytePosition = 0;
+  }
+
+  function nextToken()
+  {
+    if (count($this->_moreTokens))  {
+      $tok = array_shift($this->_moreTokens);
+      return $tok;
+    }
+    if ($this->_input == null) {
+      return null;
+    }
+
+    do {
+      /* first check for date fields */
+
+      $is_date = false;
+      // 2008-12-22T05:42:42.285445Z
+      if (preg_match('/\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d+)?Z?)?/u',
+          $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_bytePosition)) {
+        $is_date = true;
+      } else if (!preg_match('/[\p{L}\p{N}_]+/u',
+          $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_bytePosition)) {
+        return null;
+      }
+      if (!function_exists('mb_strtolower')) {
+        $matchedWord = strtolower($match[0][0]);
+      } else {
+        $matchedWord = mb_strtolower($match[0][0], 'UTF-8');
+      }
+      $binStartPos = $match[0][1];
+      $startPos = $this->_position +
+          iconv_strlen(substr($this->_input, $this->_bytePosition,
+            $binStartPos - $this->_bytePosition),
+            'UTF-8');
+      $endPos = $startPos + iconv_strlen($matchedWord, 'UTF-8');
+      $this->_bytePosition = $binStartPos + strlen($matchedWord);
+      $this->_position = $endPos;
+
+      if ($is_date) {
+//        $this->_moreTokens[] = new MTrackSearchDateToken($matchedWord,
+//          $startPos, $endPos);
+
+        /* Seems very difficult to allow range searching on strings
+         * of the form "2009-10-10", so we just smush it together */
+        $no_sep = str_replace(array('-', ':'), array('', ''), $matchedWord);
+        list($no_sep) = explode('.', $no_sep);
+
+        /* full date and time */
+//        $this->_moreTokens[] = new MTrackSearchDateToken(
+//          $no_sep, $startPos, $endPos);
+
+        /* date only */
+        $date = substr($no_sep, 0, 8);
+        $this->_moreTokens[] = new MTrackSearchDateToken(
+          $date, $startPos, $endPos);
+      } else {
+        $token = new Zend_Search_Lucene_Analysis_Token(
+          $matchedWord, $startPos, $endPos);
+        $token = $this->normalize($token);
+        if ($token !== null) {
+          $this->_moreTokens[] = $token;
+        }
+      }
+      if (!$is_date) {
+        /* split by underscores and add those tokens too */
+        foreach (explode('_', $matchedWord) as $ele) {
+          $token  = new Zend_Search_Lucene_Analysis_Token(
+            $ele, $startPos, $endPos);
+          $token = $this->normalize($token);
+          if ($token !== null) {
+            $this->_moreTokens[] = $token;
+          }
+        }
+      }
+    } while (count($this->_moreTokens) == 0);
+    return array_shift($this->_moreTokens);
+  }
+
+  function normalize(Zend_Search_Lucene_Analysis_Token $tok)
+  {
+    if ($tok instanceof MTrackSearchDateToken) {
+      return $tok;
+    }
+    return parent::normalize($tok);
+  }
+}
+
+class MTrackSearchQueryParser {
+  public $toks;
+  public $syntax;
+  public $query;
+
+  function __construct($q) {
+    $this->toks = $this->tokenize($q);
+    $this->alltoks = $this->toks;
+//    echo '<pre>', htmlentities(var_export($this->toks, true)), '</pre>';
+
+    $this->query = $this->expression();
+  }
+
+  function tokenize($string)
+  {
+    $toks = array();
+    while (strlen($string)) {
+      if (preg_match("/^\s+/", $string, $M)) {
+        $toks[] = array('white', $M[0]);
+        $string = substr($string, strlen($M[0]));
+        continue;
+      }
+      if (preg_match("/^[+!(){}^~*?:\\\[\]-]/", $string)) {
+        $toks[] = array($string[0]);
+        $string = substr($string, 1);
+        continue;
+      }
+      if (!strncmp($string, "&&", 2)) {
+        $toks[] = array("&&");
+        $string = substr($string, 2);
+        continue;
+      }
+      if (preg_match("/^and\W/i", $string, $M)) {
+        $toks[] = array("&&", $M[0]);
+        $string = substr($string, 3);
+        continue;
+      }
+      if (preg_match("/^not\W/i", $string, $M)) {
+        $toks[] = array("!", $M[0]);
+        $string = substr($string, 3);
+        continue;
+      }
+      if (!strncmp($string, "||", 2)) {
+        $toks[] = array("||");
+        $string = substr($string, 2);
+        continue;
+      }
+      if (preg_match("/^or\W/i", $string, $M)) {
+        $toks[] = array("||", $M[0]);
+        $string = substr($string, 2);
+        continue;
+      }
+      if (preg_match('/^"([^"]*)"/', $string, $M)) {
+        $toks[] = array('literal', $M[1]);
+        $string = substr($string, strlen($M[0]));
+        continue;
+      }
+      if (preg_match("/^[a-zA-Z0-9_][a-zA-Z0-9_.+-]*/", $string, $M)) {
+        $toks[] = array('literal', $M[0]);
+        $string = substr($string, strlen($M[0]));
+        continue;
+      }
+      $string = trim($string);
+      if (strlen($string)) {
+        echo "Invalid search string: <b>" . htmlentities($string) . "</b>";
+        break;
+      }
+    }
+    return $toks;
+  }
+
+  function get()
+  {
+    if (count($this->toks) == 0) {
+      return null;
+    }
+    $t = array_shift($this->toks);
+    $args = func_get_args();
+    if (count($args)) {
+      $ok = false;
+      $expected = array();
+      foreach ($args as $expect) {
+        if ($t[0] == $expect) {
+          $ok = true;
+          break;
+        }
+        $expected[] = $expect;
+      }
+      if (!$ok) {
+        $name = $t[0];
+        $value = isset($t[1]) ? $t[1] : $t[0];
+        $ntoks = count($this->alltoks);
+        $rtoks = count($this->toks);
+        $hint = '';
+        for ($i = 0; $i < $rtoks; $i++) {
+          $hint .= htmlentities($this->alltoks[$i][1], ENT_QUOTES, 'utf-8');
+        }
+        $hint .= "<b>$value</b>";
+        foreach ($this->toks as $tok) {
+          $hint .= htmlentities($tok[1]);
+        }
+        throw new Exception(
+          "Unexpected token '$value' of type $name expected " .
+          join(', ', $expected) . "<br>$hint");
+      }
+    }
+    return $t;
+  }
+
+  function peek()
+  {
+    if (!count($this->toks)) {
+      return null;
+    }
+    $t = $this->toks[0];
+    $args = func_get_args();
+    if (count($args)) {
+      $ok = false;
+      foreach ($args as $expect) {
+        if ($t[0] == $expect) {
+          $ok = true;
+          break;
+        }
+      }
+      if (!$ok) {
+        return false;
+      }
+    }
+    return $t;
+  }
+
+  function try_rule($name) {
+    $save = $this->toks;
+    try {
+      return $this->$name();
+    } catch (Exception $e) {
+      $this->toks = $save;
+      return false;
+    }
+  }
+
+  function _make_term($t, $field = null)
+  {
+    if (function_exists('mb_strtolower')) {
+      $t[1] = mb_strtolower($t[1], 'UTF-8');
+    } else {
+      $t[1] = strtolower($t[1]);
+    }
+    if ($t[0] == 'literal') {
+      $bits = preg_split("/\s+/u", $t[1]);
+
+      /* only treat it as a phrase if it is a phrase */
+      if (count($bits) > 1) {
+        $q = new Zend_Search_Lucene_Search_Query_Phrase;
+
+        foreach ($bits as $w) {
+          $t = new Zend_Search_Lucene_Index_Term($w, $field);
+          $q->addTerm($t);
+        }
+        return $q;
+      }
+    }
+
+    /* underscores and periods!
+     * if we're searching for text delimited by underscores, we
+     * rewrite that as a phrase search also */
+    $bits = preg_split("/[._]/", $t[1]);
+    if (count($bits) > 1) {
+      $q = new Zend_Search_Lucene_Search_Query_Phrase;
+
+      foreach ($bits as $w) {
+        $t = new Zend_Search_Lucene_Index_Term($w, $field);
+        $q->addTerm($t);
+      }
+      return $q;
+    }
+
+    return new Zend_Search_Lucene_Index_Term((string)$t[1], $field);
+  }
+
+  function term()
+  {
+    if ($this->peek('literal')) {
+      $t = $this->get();
+      if ($this->peek(':')) {
+        /* specific field */
+        $field = $t[1];
+        $this->get();
+
+        /* does it have a range? */
+        if ($this->peek('[')) {
+          $this->get();
+
+          $this->skipwhite();
+
+          $from = $this->get('literal');
+          $from = $this->_make_term($from, $field);
+
+          $this->skipwhite();
+          $t = $this->get('literal');
+          if (strcasecmp($t[1], 'to')) {
+            throw new Exception("Expected 'to'");
+          }
+          $this->skipwhite();
+
+          $to = $this->get('literal');
+          $to = $this->_make_term($to, $field);
+
+          $q = new Zend_Search_Lucene_Search_Query_Range(
+                $from, $to, true);
+          $this->skipwhite();
+
+          $this->get(']');
+
+          return $q;
+        }
+
+        $t = $this->get('literal');
+
+        return $this->_make_term($t, $field);
+      }
+    } else {
+      $t = $this->get('literal');
+    }
+
+    if ($t) {
+      return $this->_make_term($t);
+    }
+    return null;
+  }
+
+  function skipwhite()
+  {
+    while ($this->peek('white')) {
+      $this->get();
+    }
+  }
+
+  function expression()
+  {
+    $terms = array();
+
+    while (count($this->toks)) {
+      $modifier = null;
+
+      $this->skipwhite();
+
+      if ($this->peek('+')) {
+        $this->get();
+        $modifier = true;
+      }
+      if ($this->peek('-')) {
+        $this->get();
+        $modifier = false;
+      }
+      if ($modifier === null) {
+        $modifier = true;
+      }
+
+      $t = $this->term();
+      if ($t) {
+        $terms[] = array($t, $modifier);
+      } else {
+        break;
+      }
+    }
+
+    if (count($terms) == 0) {
+      return null;
+    }
+
+    if (count($terms) == 1) {
+      if ($terms[0][0] instanceof Zend_Search_Lucene_Search_Query) {
+        if ($terms[0][1] === null) {
+          return $terms[0][0];
+        }
+      }
+    }
+
+    $q = new Zend_Search_Lucene_Search_Query_Boolean();
+    foreach ($terms as $term) {
+      list($t, $mod) = $term;
+
+      if ($t instanceof Zend_Search_Lucene_Search_Query) {
+        $q->addSubquery($t, $mod);
+      } else {
+        $sq = new Zend_Search_Lucene_Search_Query_MultiTerm;
+        $sq->addTerm($t);
+        $q->addSubquery($sq, $mod);
+      }
+    }
+
+    return $q;
+  }
+}
+
+/* the highlighter insists on using html document things,
+ * so we force in our own dummy so that we can present the
+ * same text we used initially */
+class MTrackSearchLuceneDummyDocument {
+  public $text;
+  function __construct($text) {
+    $this->text = $text;
+  }
+  function getFieldUtf8Value($name) {
+    return $this->text;
+  }
+}
+
+class MTrackHLText
+    implements Zend_Search_Lucene_Search_Highlighter_Interface {
+  public $doc;
+  public $context = array();
+  public $text;
+  public $matched = array();
+
+  function setDocument(Zend_Search_Lucene_Document_Html $doc)
+  {
+    /* sure, I'll get right on that... */
+  }
+
+  function getDocument() {
+    /* we just return our dummy doc instead */
+    return $this->doc;
+  }
+
+  function highlight($words) {
+    if (!is_array($words)) {
+      $words = array($words);
+    }
+    foreach ($words as $word) {
+      foreach ($this->text as $line) {
+        $x = strpos($line, $word);
+        if ($x !== false) {
+          if (isset($this->matched[$word])) {
+            $this->matched[$word]++;
+          } else {
+            $this->matched[$word] = 1;
+          }
+          if (isset($this->context[$line])) {
+            $this->context[$line]++;
+          } else {
+            $this->context[$line] = 1;
+          }
+        }
+      }
+    }
+  }
+
+  function __construct($text, $query)
+  {
+    $this->doc = new MTrackSearchLuceneDummyDocument($text);
+    $text = wordwrap($text);
+    $this->text = preg_split("/\r?\n/", $text);
+    $query->htmlFragmenthighlightMatches($text, 'utf-8', $this);
+  }
+}
+
+class MTrackSearchResultLucene extends MTrackSearchResult {
+  var $_query;
+
+  function getExcerpt($text) {
+    $hl = new MTrackHLText($text, $this->_query);
+    $lines = array();
+    foreach ($hl->context as $line => $count) {
+      $line = trim($line);
+      if (!strlen($line)) continue;
+      $line = htmlentities($line, ENT_QUOTES, 'utf-8');
+      foreach ($hl->matched as $word => $wcount) {
+        $line = str_replace($word, "<span class='hl'>$word</span>", $line);
+      }
+      $lines[] = $line;
+      if (count($lines) > 6) {
+        break;
+      }
+    }
+    $ex = join(" &hellip; ", $lines);
+    if (strlen($ex)) {
+      return "<div class='excerpt'>$ex</div>";
+    }
+    return '';
+  }
+}
+
+class MTrackSearchEngineLucene implements IMTrackSearchEngine
+{
+  var $idx = null;
+
+  function getIdx() {
+    if ($this->idx) return $this->idx;
+    $ana = new MTrackSearchAnalyzer;
+    $ana->addFilter(new MTrackSearchStemmer);
+    Zend_Search_Lucene_Analysis_Analyzer::setDefault($ana);
+
+    $p = MTrackConfig::get('core', 'searchdb');
+    if (!is_dir($p)) {
+      $idx = Zend_Search_Lucene::create($p);
+      chmod($p, 0777);
+    } else {
+      $idx = Zend_Search_Lucene::open($p);
+    }
+    $this->index = $idx;
+    return $idx;
+  }
+
+  public function setBatchMode()
+  {
+    $idx = $this->getIdx();
+    $idx->setMaxBufferedDocs(64);
+    $idx->setMergeFactor(15);
+  }
+
+  public function commit($optimize = false)
+  {
+    $idx = $this->getIdx();
+    if ($optimize) {
+      $idx->optimize();
+    }
+    $idx->commit();
+    $this->idx = null;
+  }
+
+  public function add($object, $fields, $replace = false)
+  {
+    $idx = $this->getIdx();
+
+    if ($replace) {
+      $term = new Zend_Search_Lucene_Index_Term($object, 'object');
+      foreach ($idx->termDocs($term) as $id) {
+        $idx->delete($id);
+      }
+    }
+
+    $doc = new Zend_Search_Lucene_Document();
+
+    $doc->addField(Zend_Search_Lucene_Field::Text('object', $object, 'utf-8'));
+    foreach ($fields as $key => $value) {
+      if (!strlen($value)) continue;
+      if (!strncmp($key, 'stored:', 7)) {
+        $key = substr($key, 7);
+        $F = Zend_Search_Lucene_Field::Text($key, $value, 'utf-8');
+      } else {
+        $F = Zend_Search_Lucene_Field::UnStored($key, $value, 'utf-8');
+      }
+      $doc->addField($F);
+    }
+
+    $idx->addDocument($doc);
+  }
+
+  public function search($query) {
+    Zend_Search_Lucene::setTermsPerQueryLimit(150);
+    Zend_Search_Lucene::setResultSetLimit(250);
+
+    $p = new MTrackSearchQueryParser($query);
+    $q = $p->query;
+    $idx = $this->getIdx();
+    $hits = $idx->find($q);
+    $result = array();
+    foreach ($hits as $hit) {
+      $r = new MTrackSearchResultLucene;
+      $r->_query = $q;
+      $r->objectid = $hit->object;
+      $r->score = $hit->score;
+      $result[] = $r;
+    }
+    return $result;
+  }
+
+
+}
+
+
diff --git a/inc/search/solr.php b/inc/search/solr.php
new file mode 100644 (file)
index 0000000..6e798e0
--- /dev/null
@@ -0,0 +1,112 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackSearchEngineSolr implements IMTrackSearchEngine {
+  var $url;
+
+  public function __construct() {
+    $this->url = MTrackConfig::get('solr', 'url');
+  }
+
+  public function setBatchMode() {
+  }
+
+  function post($xml) {
+    $params = array(
+      'http' => array(
+        'method' => 'POST',
+        'content' => $xml,
+        'header' => 'Content-Type: text/xml',
+      ),
+    );
+    $ctx = stream_context_create($params);
+    for ($i = 0; $i < 10; $i++) {
+      $fp = fopen("$this->url/update", 'rb', false, $ctx);
+      if ($fp) {
+        fclose($fp);
+        return;
+      }
+      sleep(1);
+    }
+    throw new Exception("unable to update index; is Solr running?\n$xml\n");
+  }
+
+  public function commit($optimize = false) {
+    $this->post('<optimize/>');
+  }
+
+  public function add($object, $fields, $replace = false) {
+    $xml = "<add overwrite='true'><doc><field name='id'>$object</field>";
+    foreach ($fields as $key => $value) {
+      if (!strlen($value)) continue;
+      if (!strncmp($key, 'stored:', 7)) {
+        $key = substr($key, 7);
+      }
+
+      switch ($key) {
+        case 'date':
+        case 'created':
+          $t = strtotime($value);
+          $value = date('Y-m-d\\TH:i:s', $t) . 'Z';
+          break;
+      }
+      // avoid: HTTP/1.1 400 Illegal_character_CTRLCHAR_code_12
+      $value = str_replace("\x0c", " ", $value);
+
+      $xml .= "<field name='$key'>" .
+        htmlspecialchars($value, ENT_QUOTES, 'utf-8') .
+        "</field>";
+    }
+    $xml .= "</doc></add>";
+
+    $this->post($xml);
+  }
+
+  /** returns an array of MTrackSearchResult objects corresponding
+   * to matches to the supplied query string */
+  public function search($query) {
+    $q = http_build_query(array(
+      'q' => $query,
+      'version' => '2.2',
+      'hl' => 'on',
+      'hl.fl' => '',
+      'hl.usePhraseHighlighter' => 'on',
+      'hl.simple.pre' => "<span class='hl'>",
+      'hl.simple.post' => "</span>",
+      'fl' => 'id,score',
+      'wt' => 'json',
+      'rows' => 250,
+    ));
+    $json = file_get_contents("$this->url/select?$q");
+    $doc = json_decode($json);
+    //echo htmlentities($json);
+    //var_dump($doc);
+    $result = array();
+
+    /* look for excerpt text */
+    $hl = array();
+    foreach ($doc->highlighting as $name => $arr) {
+      $hl[$name] = array();
+      foreach ($arr as $fname => $v) {
+        foreach ($v as $a) {
+          $hl[$name][] = $a;
+        }
+      }
+    }
+
+    foreach ($doc->response->docs as $doc) {
+      $r = new MTrackSearchResult;
+      $r->objectid = $doc->id;
+      $r->score = $doc->score;
+      $r->excerpt = null;
+      if (isset($hl[$r->objectid])) {
+        $r->excerpt = "<div class='excerpt'>" .
+          join("\n", $hl[$r->objectid]) .
+          "</div>";
+      }
+      $result[] = $r;
+    }
+
+    return $result;
+  }
+}
diff --git a/inc/snippet.php b/inc/snippet.php
new file mode 100644 (file)
index 0000000..5662c94
--- /dev/null
@@ -0,0 +1,73 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackSnippet {
+  public $snid = null;
+  public $description = null;
+  public $lang = null;
+  public $snippet = null;
+  public $created = null;
+  public $updated = null;
+
+  static function loadById($id)
+  {
+    foreach (MTrackDB::q('select snid from snippets where snid = ?', $id)
+        ->fetchAll() as $row) {
+      return new self($row[0]);
+    }
+    return null;
+  }
+
+  function __construct($id = null)
+  {
+    if ($id !== null) {
+      $this->snid = $id;
+
+      list($row) = MTrackDB::q('select * from snippets where snid = ?', $id)
+        ->fetchAll(PDO::FETCH_ASSOC);
+      foreach ($row as $k => $v) {
+        $this->$k = $v;
+      }
+    }
+  }
+
+  function save(MTrackChangeset $CS)
+  {
+    $this->updated = $CS->cid;
+
+    if ($this->snid === null) {
+      $this->created = $CS->cid;
+
+      $this->snid = sha1(
+        $CS->who . ':' .
+        $CS->when . ':' .
+        $this->description . ':' .
+        $this->lang . ':' .
+        $this->snippet);
+
+      MTrackDB::q('insert into snippets
+          (snid, created, updated, description, lang, snippet)
+          values (?, ?, ?, ?, ?, ?)',
+        $this->snid,
+        $this->created,
+        $this->updated,
+        $this->description,
+        $this->lang,
+        $this->snippet
+        );
+    } else {
+      MTrackDB::q('update snippets set updated = ?,
+          description = ?, lang = ?, snippet = ?
+          WHERE snid = ?',
+        $this->updated,
+        $this->description,
+        $this->lang,
+        $this->snippet,
+        $this->snid
+        );
+    }
+  }
+}
+
+MTrackACL::registerAncestry('snippet', 'Snippets');
+
diff --git a/inc/syntax.php b/inc/syntax.php
new file mode 100644 (file)
index 0000000..f042988
--- /dev/null
@@ -0,0 +1,131 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackSyntaxHighlight {
+  static $schemes = array(
+    '' => 'No syntax highlighting',
+    'wezterm' => "Wez's Terminal",
+    'zenburn' => "Zenburn",
+    'vibrant-ink' => "Vibrant Ink",
+  );
+  static $lang_by_ext = array(
+      'c' => 'cpp',
+      'cc' => 'cpp',
+      'cpp' => 'cpp',
+      'h' => 'cpp',
+      'hpp' => 'cpp',
+      'icl' => 'cpp',
+      'ipp' => 'cpp',
+      'css' => 'css',
+      'php' => 'php',
+      'php3' => 'php',
+      'php4' => 'php',
+      'php5' => 'php',
+      'phtml' => 'php',
+      'pl' => 'perl',
+      'pm' => 'perl',
+      't' => 'perl',
+      'bash' => 'shell',
+      'sh' => 'shell',
+      'js' => 'javascript',
+      'json' => 'javascript',
+      'vb' => 'vb',
+      'xml' => 'xml',
+      'xsl' => 'xml',
+      'xslt' => 'xml',
+      'xsd' => 'xml',
+      'html' => 'xml',
+      'diff' => 'diff',
+      'patch' => 'diff',
+      'wiki' => 'wiki',
+    );
+  static $langs = array(
+    '' => 'No particular file type',
+    'cpp' => 'C/C++',
+    'css' => 'CSS (Cascading Style Sheet)',
+    'php' => 'PHP',
+    'perl' => 'Perl',
+    'shell' => 'Shell script',
+    'javascript' => 'Javascript',
+    'vb' => 'Visual Basic',
+    'xml' => 'HTML, XML, XSL',
+    'wiki' => 'Wiki Markup',
+    'diff' => 'Diff/Patch',
+  );
+
+  static function inferFileTypeFromContents($data) {
+    if (preg_match("/vim:.*ft=(\S+)/", $data, $M)) {
+      return $M[1];
+    }
+    if (preg_match("/^#!.*env\s+(\S+)/", $data, $M)) {
+      return $M[1];
+    }
+    if (preg_match("/^#!\s*(\S+)/", $data, $M)) {
+      return basename($M[1]);
+    }
+    return null;
+  }
+
+  static function highlightSource($data, $type = null, $filename = null, $line_numbers = false) {
+    if ($type === null) {
+      $type = self::inferFileTypeFromContents($data);
+      if ($type === null && $filename !== null) {
+        if (preg_match("/\.([^.]+)$/", $filename, $M)) {
+          $ext = strtolower($M[1]);
+          if (isset(self::$lang_by_ext[$ext])) {
+            $type = self::$lang_by_ext[$ext];
+          }
+        }
+      }
+    }
+    if ($type == 'diff') {
+      return mtrack_diff($data);
+    }
+    if (strlen($type) && isset(self::$langs[$type])) {
+      require_once dirname(__FILE__) . '/hyperlight/hyperlight.php';
+      $hl = new Hyperlight($type);
+      $hdata = $hl->render($data);
+    } else {
+      $hdata = htmlentities($data);
+    }
+    if (!$line_numbers) {
+      return "<span class='source-code wezterm'>$hdata</span>";
+    }
+    $lines = preg_split("/\r?\n/", $data);
+    $html = <<<HTML
+<table class='codeann'>
+  <tr>
+    <th class='line'>line</th>
+    <th class='code'>code</th>
+  </tr>
+HTML;
+    $nlines = count($lines);
+    for ($i = 1; $i <= $nlines; $i++) {
+      $html .= "<tr><td class='line'><a name='l$i'></a><a href='#l$i'>$i</a></td>";
+      if ($i == 1) {
+        $html .= "<td rowspan='$nlines' width='100%' class='source-code wezterm'>$hdata</td>";
+      }
+      $html .= "</tr>\n";
+    }
+    return $html . "</table>\n";
+  }
+
+  static function getSchemeSelect($selected = 'wezterm') {
+    $html = <<<HTML
+<select class='select-hl-scheme'>
+HTML;
+    foreach (self::$schemes as $k => $v) {
+      $sel = $selected == $k ? " selected" : '';
+      $html .= "<option value='$k'$sel>" .
+        htmlentities($v, ENT_QUOTES, 'utf-8') .
+        "</option>\n";
+    }
+    return $html . "</select>";
+  }
+
+  static function getLangSelect($name, $value) {
+    return mtrack_select_box($name, self::$langs, $value);
+  }
+
+}
+
diff --git a/inc/timeline.php b/inc/timeline.php
new file mode 100644 (file)
index 0000000..7079a83
--- /dev/null
@@ -0,0 +1,240 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+function mtrack_timeline_order_events_newest_first($a, $b)
+{
+  return strcmp($b['changedate'], $a['changedate']);
+}
+
+function mtrack_get_timeline($start_time = '-2 weeks',
+  $only_users = null, $limit = 50)
+{
+  if (is_string($start_time)) {
+    $date_limit = strtotime($start_time);
+  } else {
+    $date_limit = $start_time; // assume that it's a timestamp
+  }
+  /* round back to earlier minute (aids caching) */
+  $date_limit -= $date_limit % 60;
+  $db_date_limit = MTrackDB::unixtime($date_limit);
+  $last_date = null;
+
+  $filter_users = null;
+  if (is_string($only_users)) {
+    $filter_users = array(mtrack_canon_username($only_users));
+  } else if (is_array($only_users)) {
+    $filter_users = array();
+    foreach ($only_users as $user) {
+      $filter_users[] = mtrack_canon_username($user);
+    }
+  }
+
+  $proj_by_id = array();
+  foreach (MTrackDB::q('select projid from projects')->fetchAll() as $r) {
+    $proj_by_id[$r[0]] = MTrackProject::loadById($r[0]);
+  }
+  $events = array();
+
+  foreach (MTrackDB::q('select repoid from repos')->fetchAll() as $row) {
+    list($repoid) = $row;
+    $repo = MTrackRepo::loadById($repoid);
+    $reponame = $repo->getBrowseRootName();
+    if ($reponame == 'default/wiki') continue;
+    $checker = new MTrackCommitChecker($repo);
+
+    $hist = $repo->history(null, $db_date_limit);
+    if (is_array($hist)) foreach ($hist as $e) {
+      if (is_array($filter_users)) {
+        $wanted_user = false;
+        foreach ($filter_users as $fuser) {
+          if (mtrack_canon_username($e->changeby) === $fuser) {
+            $wanted_user = true;
+            break;
+          }
+        }
+        if (!$wanted_user) {
+          continue;
+        }
+      }
+      /* we want to include changesets that do not reference tickets */
+      $pid = $repo->projectFromPath($e->files);
+      if ($pid > 1) {
+        $proj = $proj_by_id[$pid];
+        $e->changelog = $proj->adjust_links($e->changelog, true);
+      }
+      $actions = $checker->parseCommitMessage($e->changelog);
+      $tickets = array();
+      foreach ($actions as $act) {
+        $tkt = $act[1];
+        $tickets[$tkt] = $tkt;
+        $repo_changes_by_ticket[$tkt][$reponame][$e->rev] = $e->rev;
+      }
+      if (count($tickets) == 0) {
+        $events[] = array(
+            'changedate' => $e->ctime,
+            'who' => $e->changeby,
+            'object' => "changeset:$reponame:$e->rev",
+            'reason' => $e->changelog,
+            );
+      }
+    }
+  }
+  foreach (MTrackDB::q("select 
+      changedate, who, object, reason from changes
+      where changedate > ?
+      order by changedate desc
+      ", $db_date_limit)->fetchAll(PDO::FETCH_ASSOC) as $row) {
+    if (is_array($filter_users)) {
+      $wanted_user = false;
+      foreach ($filter_users as $fuser) {
+        if (mtrack_canon_username($row['who']) === $fuser) {
+          $wanted_user = true;
+          break;
+        }
+      }
+      if (!$wanted_user) {
+        continue;
+      }
+    }
+    $events[] = $row;
+  }
+
+  usort($events, 'mtrack_timeline_order_events_newest_first');
+  return $events;
+}
+
+function _mtrack_timeline_is_repo_visible($reponame)
+{
+  static $cache = array();
+  $me = MTrackAuth::whoami();
+  if (isset($cache[$me][$reponame])) {
+    return $cache[$me][$reponame];
+  }
+
+  if (ctype_digit($reponame)) {
+    $oid = "repo:$reponame";
+  } else {
+    $repo = MTrackRepo::loadByName($reponame);
+    if ($repo) {
+      $oid = "repo:$repo->repoid";
+    } else {
+      $oid = null;
+    }
+  }
+  if ($oid) {
+    $ok = MTrackACL::hasAnyRights($oid, array(
+    'read', 'checkout'));
+  } else {
+    $ok = false;
+  }
+  $cache[$me][$reponame] = $ok;
+  return $ok;
+}
+
+function mtrack_render_timeline($user = null)
+{
+  global $ABSWEB;
+
+  $limit = 50;
+  $events = mtrack_cache('mtrack_get_timeline',
+    array('-2 weeks', $user, $limit), 300, array('Timeline', $user));
+
+  echo "<div class='timeline'>";
+  $last_date = null;
+  foreach ($events as $row) {
+    if (--$limit == 0) {
+      break;
+    }
+
+    $d = date_create($row['changedate'], new DateTimeZone('UTC'));
+    date_timezone_set($d, new DateTimeZone(date_default_timezone_get()));
+    $time = $d->format('H:i');
+    $day = $d->format('D, M d Y');
+
+    if ($last_date != $day) {
+      echo "<h1 class='timelineday'>$day</h1>\n";
+      $last_date = $day;
+    }
+
+    // figure out an event type based on the object and the reason
+    if (strpos($row['object'], ':') !== false) {
+      list($object, $id) = explode(':', $row['object'], 3);
+    } else {
+      $id = 0;
+      $object = $row['object'];
+    }
+    $eventclass = ''; 
+    $item = $row['object'];
+    switch ($object) {
+      case 'ticket':
+        if (!strncmp($row['reason'], 'created ', 8)) {
+          $eventclass = ' newticket';
+        } elseif (!strncmp($row['reason'], 'closed ', 7)) {
+          $eventclass = ' closedticket';
+        } else {
+          $eventclass = ' editticket';
+        }
+        $item = "Ticket " . mtrack_ticket($id);
+        break;
+      case 'wiki':
+        $eventclass = ' editwiki';
+        $item = "Wiki " . mtrack_wiki($id);
+        break;
+      case 'milestone':
+        $eventclass = ' editmilestone';
+        $item = "Milestone <span class='milestone'><a href='{$ABSWEB}milestone.php/$id'>$id</a></span>";
+        break;
+      case 'changeset':
+        $eventclass = ' newchangeset';
+        preg_match("/^changeset:(.*):([^:]+)$/", $row['object'], $M);
+        $repo = $M[1];
+        if (!_mtrack_timeline_is_repo_visible($repo)) {
+          continue 2;
+        }
+        $id = $M[2];
+        $item = "<a href='{$ABSWEB}browse.php/$repo'>$repo</a> change " . mtrack_changeset($id, $repo);
+        break;
+      case 'snippet':
+        $item = "<a href='{$ABSWEB}snippet.php/$id'>View Snippet</a>";
+        break;
+      case 'repo':
+        static $repos = null;
+        if ($repos === null) {
+          $repos = array();
+          foreach (MTrackDB::q(
+              'select repoid, shortname, parent from repos')->fetchAll()
+              as $r) {
+            $repos[$r[0]] = $r;
+          }
+        }
+        if (!_mtrack_timeline_is_repo_visible($id)) {
+          continue 2;
+        }
+        if (isset($repos[$id])) {
+          $name = MTrackRepo::makeDisplayName($repos[$id]);
+          $item = "<a href='{$ABSWEB}browse.php/$name'>$name</a>";
+        } else {
+          $item = "&lt;item has been deleted&gt;";
+        }
+        break;
+    }
+
+    $reason = MTrackWiki::format_to_oneliner($row['reason']);
+
+    echo "<div class='timelineevent'>",
+      mtrack_username($row['who'], array(
+        'no_name' => true,
+        'size' => 48,
+        'class' => 'timelineface'
+        )),
+      "<div class='timelinetext'>",
+      "<div class='timelinereason'>",
+      "$reason</div>\n",
+      "<span class='time'>$time</span> $item by ",
+      mtrack_username($row['who'], array('no_image' => true)),
+      "</div>\n";
+    echo "</div>\n";
+  }
+  echo "</div>\n";
+}
+
diff --git a/inc/watch.php b/inc/watch.php
new file mode 100644 (file)
index 0000000..b93852e
--- /dev/null
@@ -0,0 +1,439 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackWatch {
+  static $possible_event_types = array();
+  static $media = array(
+    'email' => 'Email',
+//    'timline' => 'Timeline'
+  );
+
+  static function registerEventTypes($objecttype, $events) {
+    self::$possible_event_types[$objecttype] = $events;
+  }
+
+  static function getWatchUI($object, $id) {
+    ob_start();
+    self::renderWatchUI($object, $id);
+    $res = ob_get_contents();
+    ob_end_clean();
+    return $res;
+  }
+
+  static function renderWatchUI($object, $id) {
+    $me = mtrack_canon_username(MTrackAuth::whoami());
+    if ($me == 'anonymous' || MTrackAuth::getUserClass() == 'anonymous') {
+      return;
+    }
+
+    global $ABSWEB;
+    $url = $ABSWEB . 'admin/watch.php?' .
+      http_build_query(array('o' => $object, 'i' => $id));
+    $evts = json_encode(self::$possible_event_types[$object]);
+    $media = json_encode(self::$media);
+    $val = new stdclass;
+    foreach (MTrackDB::q('select medium, event from watches where otype = ? and oid = ? and userid = ? and active = 1', $object, $id, $me)->fetchAll() as $row)
+    {
+      $val->{$row['medium']}->{$row['event']} = true;
+    }
+    $val = json_encode($val);
+    echo <<<HTML
+ <button id='watcher-$object-$id' type='button'>Watch</button>
+<script>
+$(document).ready(function () {
+  var evts = $evts;
+  var media = $media;
+  var V = $val;
+  $('#watcher-$object-$id').click(function () {
+    var dlg = $('<div title="Watching"/>');
+    var frm = $('<form/>');
+    var tbl = $('<table/>');
+    var tr = $('<tr/>');
+    tr.append('<th>Event</th>');
+    for (var m in media) {
+      var th = $('<th/>');
+      th.text(media[m]);
+      tr.append(th);
+    }
+    tbl.append(tr);
+
+    for (var i in evts) {
+      tr = $('<tr/>');
+      var td = $('<td/>');
+      td.text(evts[i]);
+      tr.append(td);
+
+      for (var m in media) {
+        td = $('<td/>');
+        var cb = $('<input type="checkbox"/>');
+        if (V[m] && V[m][i]) {
+          cb.attr('checked', 'checked');
+        }
+        cb.data('medium', m);
+        cb.data('event', i);
+        td.append(cb);
+        tr.append(td);
+      }
+      tbl.append(tr);
+    }
+    frm.append(tbl);
+    dlg.append(frm);
+    dlg.dialog({
+      autoOpen: true,
+      bgiframe: true,
+      resizable: false,
+      width: 600,
+      modal: true,
+      buttons: {
+        'Ok': function() {
+          V = {};
+          $("input[type='checkbox'][checked]", dlg).each(function () {
+            var m = $(this).data('medium');
+            var e = $(this).data('event');
+            if (!V[m]) {
+              V[m] = {};
+            }
+            V[m][e] = true;
+          });
+          $.post('$url', {w: JSON.stringify(V)});
+          $(this).dialog('close');
+          dlg.remove();
+        }
+      }
+    });
+  });
+});
+</script>
+HTML;
+  }
+
+  /* Returns an array, keyed by watching entity, of objects that changed
+   * since the specified date.
+   * $watcher = null means all watchers, otherwise specifies the only watcher of interest.
+   * $medium specifies timeline or email (or some other medium)
+   */
+  static function getWatchedItemsAndWatchers($since, $medium, $watcher = null) {
+    if ($watcher) {
+      $q = MTrackDB::q('select otype, oid, userid, event from watches where active = 1 and medium = ? and userid = ?', $medium, $watcher);
+    } else {
+      $q = MTrackDB::q('select otype, oid, userid, event from watches where active = 1 and medium = ?', $medium);
+    }
+    $watches = $q->fetchAll(PDO::FETCH_ASSOC);
+
+    $last = strtotime($since);
+    $LATEST = $last;
+
+    $db = MTrackDB::get();
+    $changes = MTrackDB::q(
+      "select * from changes where changedate > ? order by changedate asc",
+      MTrackDB::unixtime($last))->fetchAll(PDO::FETCH_OBJ);
+    $cids = array();
+    $cs_by_cid = array();
+    $by_object = array();
+    foreach ($changes as $CS) {
+      $cids[] = $CS->cid;
+      $cs_by_cid[$CS->cid] = $CS;
+      $t = strtotime($CS->changedate);
+      if ($t > $LATEST) {
+        $LATEST = $t;
+      }
+
+      list($object, $id) = explode(':', $CS->object, 3);
+      $by_object[$object][$id][] = $CS->cid;
+    }
+
+    $repo_by_id = array();
+    $changesets_by_repo_and_rev = array();
+    $related_projects = array();
+
+    foreach (MTrackDB::q('select repoid from repos')
+        ->fetchAll(PDO::FETCH_COLUMN, 0) as $repoid) {
+      $repo = MTrackRepo::loadById($repoid);
+      $repo_by_id[$repoid] = $repo;
+
+      foreach ($repo->history(null, MTrackDB::unixtime($last)) as $e) {
+        /* SCM doesn't always respect our date range */
+        $t = strtotime($e->ctime);
+        if ($t <= $last) {
+          continue;
+        }
+        if ($t > $LATEST) {
+          $LATEST = $t;
+        }
+
+        $key = $repo->getBrowseRootName() . ',' . $e->rev;
+        $e->repo = $repo;
+        $changesets_by_repo_and_rev[$key] = $e;
+
+        $e->_related = array();
+
+        /* relationships to projects based on path */
+        $projid = $repo->projectFromPath($e->files);
+        if ($projid !== null) {
+          $e->_related[] = array('project', $projid);
+          $related_projects[$projid] = $projid;
+        }
+      }
+    }
+
+    /* Ensure that changesets are sorted chronologically */
+    uasort($changesets_by_repo_and_rev, array('MTrackWatch', '_compare_cs'));
+
+    /* Look at the changed tickets: match the reason back to one of the
+     * above changesets */
+    if (isset($by_object['ticket'])) {
+      foreach ($by_object['ticket'] as $tid => $cslist) {
+        foreach ($cslist as $cid) {
+          $CS = $cs_by_cid[$cid];
+          if (!preg_match_all(
+                "/\(In \[changeset:(([^,]+),([a-zA-Z0-9]+))\]\)/sm",
+                $CS->reason, $CSM)) {
+            continue;
+          }
+          // $CSM[2] => repo
+          // $CSM[3] => changeset
+          foreach ($CSM[2] as $csm => $csm_repo) {
+            $csm_rev = $CSM[3][$csm];
+
+            /* Look for the repo changeset */
+            $key = "$csm_repo,$csm_rev";
+            if (isset($changesets_by_repo_and_rev[$key])) {
+              $e = $changesets_by_repo_and_rev[$key];
+              $e->CS = $CS;
+              $CS->ent = $e;
+            }
+          }
+        }
+      }
+    }
+
+    $tkt_list = array();
+    $proj_by_tid = array();
+    $emails_by_tid = array();
+    $emails_by_pid = array();
+    $owners_by_csid = array();
+    $milestones_by_tid = array();
+    $milestones_by_cid = array();
+
+    /* determine linked projects and group emails */
+    if (count($related_projects)) {
+      $projlist = join(',', $related_projects);
+      foreach (MTrackDB::q(
+          "select projid, notifyemail from projects where
+          notifyemail is not null and projid in ($projlist)")
+          ->fetchAll(PDO::FETCH_NUM) as $row) {
+        $emails_by_pid[$row[0]] = $row[1];
+      }
+    }
+
+    if (isset($by_object['ticket'])) {
+      $tkt_owner_ids = array();
+      $tkt_cid_list = array();
+      $tkt_milestone_fields = array();
+
+      foreach ($by_object['ticket'] as $tid => $cidlist) {
+        $tkt_list[] = $db->quote($tid);
+        $tkt_owner_ids[] = $db->quote("ticket:$tid:owner");
+        foreach ($cidlist as $cid) {
+          $tkt_cid_list[$cid] = $cid;
+        }
+        /* also want to include folks that were Cc'd */
+        $tkt_owner_ids[] = $db->quote("ticket:$tid:cc");
+        /* milestones */
+        $tkt_milestone_fields[] = $db->quote("ticket:$tid:@milestones");
+      }
+      $tkt_list = join(',', $tkt_list);
+
+      foreach (MTrackDB::q(
+          "select t.tid, p.projid, notifyemail from tickets t left join ticket_components tc on t.tid = tc.tid left join components_by_project cbp on cbp.compid = tc.compid left join projects p on cbp.projid = p.projid where p.projid is not null and t.tid in ($tkt_list)")->fetchAll(PDO::FETCH_NUM) as $row) {
+        $proj_by_tid[$row[0]][$row[1]] = $row[1];
+        if (isset($row[2]) && strlen($row[2])) {
+          $emails_by_tid[$row[0]] = $row[2];
+          $emails_by_pid[$row[1]] = $row[2];
+        }
+      }
+
+      /* determine all changed owners in the affected period */
+      $tkt_owner_ids = join(',', $tkt_owner_ids);
+      $tkt_cid_list = join(',', $tkt_cid_list);
+      foreach (MTrackDB::q(
+          "select cid, oldvalue, value from change_audit where cid in ($tkt_cid_list) and fieldname in ($tkt_owner_ids)")->fetchAll(PDO::FETCH_NUM) as $row) {
+        $cid = array_shift($row);
+        foreach ($row as $owner) {
+          if (!strlen($owner)) continue;
+          $owners_by_csid[$cid][$owner] = mtrack_canon_username($owner);
+        }
+      }
+
+      /* determine all changed milestones in the affected period */
+      $tkt_milestone_fields = join(',', $tkt_milestone_fields);
+      foreach (MTrackDB::q(
+          "select cid, oldvalue, value from change_audit where cid in ($tkt_cid_list) and fieldname in ($tkt_milestone_fields)")->fetchAll(PDO::FETCH_NUM) as $row) {
+        $cid = array_shift($row);
+        foreach ($row as $ms) {
+          $ms = split(',', $ms);
+          foreach ($ms as $mid) {
+            $mid = (int)$mid;
+            $milestones_by_cid[$cid][$mid] = $mid;
+          }
+        }
+      }
+
+      foreach (MTrackDB::q(
+          "select tid, mid from ticket_milestones where tid in ($tkt_list)")
+          ->fetchAll(PDO::FETCH_NUM) as $row) {
+        $milestones_by_tid[$row[0]][$row[1]] = $row[1];
+      }
+    }
+
+    /* walk through list of objects and add related objects */
+    if (isset($by_object['ticket'])) {
+      foreach ($by_object['ticket'] as $tid => $cslist) {
+        foreach ($cslist as $cid) {
+          $CS = $cs_by_cid[$cid];
+          if (!isset($CS->_related)) {
+            $CS->_related = array();
+          }
+
+          if (isset($CS->ent)) {
+            $CS->_related[] = array('repo', $CS->ent->repo->repoid);
+          }
+          if (isset($proj_by_tid[$tid])) {
+            foreach ($proj_by_tid[$tid] as $pid) {
+              $CS->_related[] = array('project', $pid);
+            }
+          }
+          if (isset($milestones_by_tid[$tid])) {
+            foreach ($milestones_by_tid[$tid] as $mid) {
+              $CS->_related[] = array('milestone', $mid);
+            }
+          }
+          if (isset($milestones_by_cid[$cid])) {
+            foreach ($milestones_by_cid[$cid] as $mid) {
+              $CS->_related[] = array('milestone', $mid);
+            }
+          }
+        }
+      }
+    }
+    foreach ($changesets_by_repo_and_rev as $ent) {
+      $ent->_related[] = array('repo', $ent->repo->repoid);
+    }
+
+    /* having determined all changed items, make a pass through to determine
+     * how to associate those with watchers.
+     * Watchers are one of:
+     * - an user with a matching watches entry
+     * - the group email address associated with a project associated with the
+     *   changed object
+     * - the owner of a ticket
+     */
+
+    /* generate synthetic watcher entries for project group emails */
+    foreach ($emails_by_pid as $pid => $email) {
+      $watches[] = array(
+        'otype' => 'project',
+        'oid' => $pid,
+        'userid' => $email,
+        'event' => '*',
+      );
+    }
+
+    foreach ($by_object as $otype => $objects) {
+      foreach ($objects as $oid => $cidlist) {
+        foreach ($cidlist as $cid) {
+          $CS = $cs_by_cid[$cid];
+          if (isset($owners_by_csid[$cid])) {
+            /* add synthetic watcher for a past or current owner */
+            foreach ($owners_by_csid[$cid] as $owner) {
+              $watches[] = array(
+                'otype' => $otype,
+                'oid' => $oid,
+                'userid' => $owner,
+                'event' => '*'
+              );
+            }
+          }
+          self::_compute_watch($watches, $otype, $oid, $CS);
+          /* eliminate from the set if there are no watchers */
+          if (!isset($CS->_watcher)) {
+            unset($cs_by_cid[$cid]);
+          }
+        }
+      }
+    }
+    foreach ($changesets_by_repo_and_rev as $key => $ent) {
+      self::_compute_watch($watches, 'changeset', $key, $ent);
+      /* eliminate from the set if there are no watchers */
+      if (!isset($ent->_watcher)) {
+        unset($changesets_by_repo_and_rev[$key]);
+      }
+    }
+
+    /* now collect the data by watcher */
+    $by_watcher = array();
+    foreach ($cs_by_cid as $CS) {
+      foreach ($CS->_watcher as $user) {
+        $by_watcher[$user][$CS->cid] = $CS;
+      }
+    }
+    foreach ($changesets_by_repo_and_rev as $key => $ent) {
+      foreach ($ent->_watcher as $user) {
+        /* don't add this if we have an associated CS */
+        if (isset($ent->CS) && $by_watcher[$user][$ent->CS->cid]) {
+          continue;
+        }
+        $by_watcher[$user][$key] = $ent;
+      }
+    }
+    /* one last pass to group the data by object */
+    foreach ($by_watcher as $user => $items) {
+      foreach ($items as $key => $obj) {
+        if ($obj instanceof MTrackSCMEvent) {
+          /* group by repo */
+          $nkey = "repo:" . $obj->repo->repoid;
+        } else {
+          $nkey = $obj->object;
+        }
+        unset($by_watcher[$user][$key]);
+        $by_watcher[$user][$nkey][] = $obj;
+      }
+    }
+
+    return $by_watcher;
+  }
+
+  static function _compute_watch($watches, $otype, $oid, $obj, $event = null) {
+    foreach ($watches as $row) {
+      if ($row['otype'] != $otype) continue;
+      if ($row['oid'] != '*' && $row['oid'] != $oid) continue;
+      if ($event === null || $row['event'] == '*' || $row['event'] == $event) {
+        if (!isset($obj->_watcher)) {
+          $obj->_watcher = array();
+        }
+        $obj->_watcher[$row['userid']] = $row['userid'];
+      }
+    }
+    if ($event === null && isset($obj->_related)) {
+      foreach ($obj->_related as $rel) {
+        self::_compute_watch($watches, $rel[0], $rel[1], $obj, $otype);
+      }
+    }
+  }
+
+  static function _get_project($pid) {
+    static $projects = array();
+    if (isset($projects[$pid])) {
+      return $projects[$pid];
+    }
+    $projects[$pid] = MTrackProject::loadById($pid);
+    return $projects[$pid];
+  }
+
+  /* comparison function for MTrackSCMEvent objects that sorts in ascending
+   * chronological order */
+  static function _compare_cs($A, $B) {
+    return strcmp($A->ctime, $B->ctime);
+  }
+}
+
diff --git a/inc/web.php b/inc/web.php
new file mode 100644 (file)
index 0000000..7b5d52f
--- /dev/null
@@ -0,0 +1,1131 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+/* Simplistic pathinfo parsing - could optionally have additional features such
+  as validation added */
+function mtrack_parse_pathinfo($vars) {
+  $pi = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '';
+  $data = explode('/', $pi);
+  $i = 0;
+  $return_vars = array();
+  array_shift($data);
+  foreach($vars as $name => $value) {
+    if (isset($data[$i])) {
+      $return_vars[$name] = $data[$i];
+      $i++;
+    } else {
+      $return_vars[$name] = $value;
+    }
+  }
+  return $return_vars;
+}
+
+/* Pathinfo retrieval minus starting slash */
+function mtrack_get_pathinfo($no_strip = false) {
+  $pi = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : NULL;
+  if ($pi !== NULL && strlen($pi) && $no_strip == false) {
+    $pi = substr($pi, 1);
+  }
+  return $pi;
+}
+
+function mtrack_calc_root()
+{
+  /* ABSWEB: the absolute URL to the base of the web app */
+  global $ABSWEB;
+
+  /* if they have one, use the weburl config value for this */
+  $ABSWEB = MTrackConfig::get('core', 'weburl');
+  if (strlen($ABSWEB)) {
+    return;
+  }
+
+  /* otherwise, determine the root of the app.
+   * This is complicated because the DOCUMENT_ROOT may refer to an area that
+   * is completely unrelated to the actual root of the web application, for
+   * instance, in the case that the user has a public_html dir where they
+   * are running mtrack */
+
+  /* determine the root of the app */
+  $sdir = dirname($_SERVER['SCRIPT_FILENAME']);
+  $idir = dirname(dirname(__FILE__)) . '/web';
+  $diff = substr($sdir, strlen($idir)+1);
+  $rel = preg_replace('@[^/]+@', '..', $diff);
+  if (strlen($rel)) {
+    $rel .= '/';
+  }
+  /* $rel is now the relative path to the root of the web app, from the current
+   * page */
+
+  if (isset($_SERVER['HTTP_HOST'])) {
+    $ABSWEB = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ?
+              'https' : 'http') . '://' .  $_SERVER['HTTP_HOST'];
+  } else {
+    $ABSWEB = 'http://localhost';
+  }
+
+  $bits = explode('/', $rel);
+  $base = $_SERVER['SCRIPT_NAME'];
+  foreach ($bits as $b) {
+    $base = dirname($base);
+  }
+  if ($base == '/') {
+    $ABSWEB .= '/';
+  } else {
+    $ABSWEB .= $base . '/';
+  }
+}
+mtrack_calc_root();
+
+function mtrack_head($title, $navbar = true)
+{
+  global $ABSWEB;
+  static $mtrack_did_head;
+
+  $whoami = mtrack_username(MTrackAuth::whoami(),
+    array(
+      'no_image' => true
+    )
+  );
+
+  if ($mtrack_did_head) {
+    return;
+  }
+  $mtrack_did_head = true;
+
+  $projectname = htmlentities(MTrackConfig::get('core', 'projectname'),
+    ENT_QUOTES, 'utf-8');
+  $logo = MTrackConfig::get('core', 'projectlogo');
+  if (strlen($logo)) {
+    $projectname = "<img alt='$projectname' src='$logo'>";
+  }
+  $fav = MTrackConfig::get('core', 'favicon');
+  if (strlen($fav)) {
+    $fav = <<<HTML
+<link rel="icon" href="$fav" type="image/x-icon" />
+<link rel="shortcut icon" href="$fav" type="image/x-icon" />
+HTML;
+  } else {
+    $fav = '';
+  }
+
+  $title = htmlentities($title, ENT_QUOTES, 'utf-8');
+
+  $userinfo = "Logged in as $whoami";
+  MTrackNavigation::augmentUserInfo($userinfo);
+
+  echo <<<HTML
+<!DOCTYPE html>
+<html>
+<head>
+<meta http-equiv="Content-Type" value="text/html; charset=utf-8">
+<meta http-equiv="X-UA-Compatible" content="IE=8">
+<title>$title</title>
+$fav
+<link rel="stylesheet" href="${ABSWEB}css.php" type="text/css" />
+<script language="javascript" type="text/javascript" src="${ABSWEB}js.php"></script>
+</head>
+<body>
+HTML;
+
+  if ($navbar) {
+    echo <<<HTML
+<div id="banner-back">
+  <form id="mainsearch" action="${ABSWEB}search.php">
+    $userinfo
+    <input type="text" class="search" title="Type and press enter to Search"
+        name="q" accesskey="f">
+  </form>
+  <div id="banner">
+    $projectname
+  </div>
+HTML;
+
+    echo <<<HTML
+<div id="header">
+HTML;
+
+  $nav = array();
+  if (MTrackAuth::whoami() !== 'anonymous') {
+    $nav['/'] = 'Today';
+  }
+  $navcandidates = array(
+    "/browse.php" => array("Browse", 'read', 'Browser'),
+    "/wiki.php" => array("Wiki", 'read', 'Wiki'),
+    "/timeline.php" => array("Timeline", 'read', 'Timeline'),
+    "/roadmap.php" => array("Roadmap", 'read', 'Roadmap'),
+    "/reports.php" => array("Reports", 'read', 'Reports'),
+    "/ticket.php/new" => array("New Ticket", 'create', 'Tickets'),
+    "/snippet.php" => array("Snippets", 'read', 'Snippets'),
+    "/admin/" => array("Administration", 'modify', 'Enumerations', 'Components', 'Projects', 'Browser'),
+  );
+  foreach ($navcandidates as $url => $data) {
+    $label = array_shift($data);
+    $right = array_shift($data);
+    $ok = false;
+    foreach ($data as $object) {
+      if (MTrackACL::hasAllRights($object, $right)) {
+        $ok = true;
+        break;
+      }
+    }
+    if ($ok) {
+      $nav[$url] = $label;
+    }
+  }
+
+  echo mtrack_nav('mainnav', $nav);
+  echo <<<HTML
+  </div>
+HTML;
+  }
+  if (MTrackConfig::get('core', 'admin_party') == 1 &&
+      MTrackAuth::whoami() == 'adminparty' &&
+      ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' ||
+          $_SERVER['REMOTE_ADDR'] == '::1')) {
+    echo <<<HTML
+<div class='ui-state-error ui-corner-all'>
+    <span class='ui-icon ui-icon-alert'></span>
+  <b>Welcome to the admin party!</b> Authentication is not yet configured;
+  while it is in this state, any user connecting from the localhost
+  address is treated as having admin rights (that includes you, and this
+  is why you are seeing this message). All other users are treated
+  as anonymous users.<br>
+  <b><a href="{$ABSWEB}admin/auth.php">Click here to Configure Authentication</a></b>
+</div>
+HTML;
+  } elseif (!MTrackAuth::isAuthConfigured() &&
+      MTrackConfig::get('core', 'admin_party') == 1)
+  {
+    $localaddr = preg_replace('@^(https?://)([^/]+)/(.*)$@',
+      "\\1localhost/\\3", $ABSWEB);
+
+    echo <<<HTML
+<div class='ui-state-highlight ui-corner-all'>
+    <span class='ui-icon ui-icon-info'></span>
+  <b>Authentication is not yet configured</b>.  If you are the admin,
+  you should use the <b><a href="$localaddr">localhost address</a></b>
+  to reach the system and configure it.
+</div>
+HTML;
+  } elseif (!MTrackAuth::isAuthConfigured()) {
+    echo <<<HTML
+<div class='ui-state-highlight ui-corner-all'>
+    <span class='ui-icon ui-icon-info'></span>
+  <b>Authentication is not yet configured</b>.  If you are the admin,
+  you will need to edit the config.ini file to configure authentication.
+</div>
+HTML;
+  }
+
+  if (ini_get('magic_quotes_gpc') === true ||
+      !strcasecmp(ini_get('magic_quotes_gpc'), 'on')) {
+    echo <<<HTML
+<div class='ui-state-error ui-corner-all'>
+    <span class='ui-icon ui-icon-alert'></span>
+  <b>magic_quotes_gpc</b> is enabled.  This causes mtrack not to work.
+  Please disable this setting in your server configuration.
+</div>
+HTML;
+
+  }
+
+  echo <<<HTML
+</div>
+<div id="content">
+HTML;
+}
+
+function mtrack_foot($visible_markup = true)
+{
+  echo <<<HTML
+</div>
+HTML;
+  if ($visible_markup) {
+    echo <<<HTML
+<div id="footer">
+<div class="navfoot">
+  Powered by <a href="http://bitbucket.org/wez/mtrack/">mtrack</a>
+</div>
+</div>
+</body>
+<script>
+\$(document).ready(function () {
+  window.mtrack_footer_position();
+});
+</script>
+</html>
+HTML;
+    if (MTrackConfig::get('core', 'debug.footer')) {
+      global $FORKS;
+
+      echo "<!-- " . MTrackDB::$queries . " queries\n";
+      var_export(MTrackDB::$query_strings);
+      echo "\n\nforks\n\n";
+      var_export($FORKS);
+      echo "-->";
+    }
+  }
+}
+
+interface IMTrackExtensionPage {
+  /** called to dispatch a page render */
+  function dispatchRequest();
+}
+
+class MTrackExtensionPage {
+  static $locations = array();
+  static function registerLocation($location, IMTrackExtensionPage $page) {
+    self::$locations[$location] = $page;
+  }
+  static function locationToURL($location) {
+    global $ABSWEB;
+    return $ABSWEB . 'ext.php/' . $location;
+  }
+  static function bindToPage($location) {
+    while (strlen($location)) {
+      if (isset(self::$locations[$location])) {
+        return self::$locations[$location];
+      }
+      if (strpos($location, '/') === false) {
+        return null;
+      }
+      $location = dirname($location);
+    }
+  }
+}
+
+interface IMTrackNavigationHelper {
+  /** called by mtrack_nav
+   * You may remove items from or add items to the items array by
+   * changing the $items array.
+   * Should you want to suppress the Wiki from navigation, you may
+   * do so like this:
+   * if ($id == 'mainnav') {
+   *   unset($items['/wiki.php']);
+   * }
+   * If you want to add an item, the key is the URL and the value
+   * is the label.  The label is raw HTML.
+   */
+  function augmentNavigation($id, &$items);
+
+  /** called by mtrack_head
+   * You may augment or override the "Logged in as user" text by
+   * changing the $content variable */
+  function augmentUserInfo(&$content);
+}
+
+class MTrackNavigation {
+  static $helpers = array();
+
+  static function registerHelper(IMTrackNavigationHelper $helper)
+  {
+    self::$helpers[] = $helper;
+  }
+
+  static function augmentNavigation($id, &$items)
+  {
+    foreach (self::$helpers as $helper) {
+      $helper->augmentNavigation($id, $items);
+    }
+  }
+
+  static function augmentUserInfo(&$content)
+  {
+    foreach (self::$helpers as $helper) {
+      $helper->augmentUserInfo($content);
+    }
+  }
+}
+
+function mtrack_nav($id, $nav) {
+  global $ABSWEB;
+
+  // Allow config file to manipulate the navigation bits
+  $cnav = MTrackConfig::getSection('nav:' . $id);
+  if (is_array($cnav)) {
+    foreach ($cnav as $loc => $label) {
+      if (!strlen($label)) {
+        unset($nav[$loc]);
+      } else {
+        $nav[$loc] = $label;
+      }
+    }
+  }
+
+  MTrackNavigation::augmentNavigation($id, $nav);
+
+  $elements = array();
+
+  $web = realpath(dirname(__FILE__) . '/../web');
+  $where = substr($_SERVER['SCRIPT_FILENAME'], strlen($web));
+  if (isset($_SERVER['PATH_INFO'])) {
+    $where .= $_SERVER['PATH_INFO'];
+  }
+  $active = null;
+  $tries = 0;
+  do {
+    foreach ($nav as $loc => $label) {
+      $cloc = $loc;
+      if (!strncmp($cloc, $ABSWEB, strlen($ABSWEB))) {
+        $cloc = substr($cloc, strlen($ABSWEB)-1);
+      }
+      if ($where == $cloc || $where == rtrim($cloc, '/')) {
+        $active = $loc;
+        break;
+      }
+    }
+    $where = dirname($where);
+  } while ($active === null && $tries++ < 100);
+
+  foreach ($nav as $loc => $label) {
+    unset($nav[$loc]);
+    $class = array();
+    if (!count($elements)) {
+      $class[] = "first";
+    }
+    if (count($nav) == 0) {
+      $class[] = "last";
+    }
+    if ($active == $loc) {
+      $class[] = 'active';
+    }
+    if (count($class)) {
+      $class = " class=\"" . implode(' ', $class) . "\"";
+    } else {
+      $class = '';
+    }
+    if ($loc[0] == '/') {
+      $url = substr($loc, 1); // trim off leading /
+    } else {
+      $url = $loc;
+    }
+    if (!preg_match('/^[a-z-]+:/', $url)) {
+      $url = $ABSWEB . $url;
+    }
+    $elements[] = "<li$class><a href=\"$url\">$label</a></li>";
+  }
+  return "<div id='$id' class='nav'><ul>" .
+    implode('', $elements) . "</ul></div>";
+}
+
+function mtrack_date($tstring, $show_full = false)
+{
+  /* database time is always relative to UTC */
+  $d = date_create($tstring, new DateTimeZone('UTC'));
+  if (!is_object($d)) {
+    throw new Exception("could not represent $tstring as a datetime object");
+  }
+  $iso8601 = $d->format(DateTime::W3C);
+  /* but we want to render relative to user prefs */
+  date_timezone_set($d, new DateTimeZone(date_default_timezone_get()));
+  $full = $d->format('D, M d Y H:i');
+
+  if (!$show_full) {
+    return "<abbr title=\"$iso8601\" class='timeinterval'>$full</abbr>";
+  }
+
+  return "<abbr title='$iso8601' class='timeinterval'>$full</abbr> <span class='fulldate'>$full</span>";
+}
+
+function mtrack_rmdir($dir)
+{
+  foreach (scandir($dir) as $ent) {
+    if ($ent == '.' || $ent == '..') {
+      continue;
+    }
+    $full = $dir . DIRECTORY_SEPARATOR . $ent;
+    if (is_dir($full)) {
+      mtrack_rmdir($full);
+    } else {
+      unlink($full);
+    }
+  }
+  rmdir($dir);
+}
+
+function mtrack_make_temp_dir($do_make = true)
+{
+  $tempdir = sys_get_temp_dir();
+  $base = $tempdir . DIRECTORY_SEPARATOR . "mtrack." . uniqid();
+  for ($i = 0; $i < 1024; $i++) {
+    $candidate = $base . sprintf("%04x", $i);
+    if ($do_make) {
+      if (mkdir($candidate)) {
+        return $candidate;
+      }
+    } else {
+      /* racy */
+      if (!file_exists($candidate) && !is_dir($candidate)) {
+        return $candidate;
+      }
+    }
+  }
+  throw new Exception("unable to make temp dir based on path $candidate");
+}
+
+function mtrack_diff_strings($before, $now)
+{
+  $tempdir = sys_get_temp_dir();
+  $afile = tempnam($tempdir, "mtrack");
+  $bfile = tempnam($tempdir, "mtrack");
+  file_put_contents($afile, $before);
+  file_put_contents($bfile, $now);
+  $diff = MTrackConfig::get('tools', 'diff');
+  if (PHP_OS == 'SunOS') {
+     // TODO: make an option to allow use of gnu diff on solaris
+    $diff = shell_exec("$diff -u $afile $bfile");
+    $diff = str_replace($afile, 'before', $diff);
+    $diff = str_replace($bfile, 'now', $diff);
+  } else {
+    $diff = shell_exec("$diff --label before --label now -u $afile $bfile");
+  }
+  unlink($afile);
+  unlink($bfile);
+  $diff = htmlentities($diff, ENT_COMPAT, 'utf-8');
+  return $diff;
+}
+
+function mtrack_last_chance_saloon($e)
+{
+  if ($e instanceof MTrackAuthorizationException) {
+    if (MTrackAuth::whoami() == 'anonymous') {
+      MTrackAuth::forceAuthenticate();
+    }
+    mtrack_head('Insufficient Privilege');
+    echo '<h1>Insufficient Privilege</h1>';
+    $rights = is_array($e->rights) ? join(', ', $e->rights) : $e->rights;
+    echo "You do not have the required set of rights ($rights) to access this page<br>";
+    mtrack_foot();
+    exit;
+  }
+
+  $msg = $e->getMessage();
+
+  try {
+    mtrack_head('Whoops: ' . $msg);
+  } catch (Exception $doublefault) {
+  }
+
+  echo "<h1>An error occurred!</h1>";
+
+  echo htmlentities($msg, ENT_QUOTES, 'utf-8');
+
+  echo "<br>";
+
+  echo nl2br(htmlentities($e->getTraceAsString(), ENT_QUOTES, 'utf-8'));
+
+  try {
+    mtrack_foot();
+  } catch (Exception $doublefault) {
+  }
+}
+
+function mtrack_canon_username($username)
+{
+  static $canon_map = null;
+
+  if ($canon_map === null) {
+    $canon_map = array();
+    foreach (MTrackDB::q('select alias, userid from useraliases union select email, userid from userinfo where email <> \'\'')->fetchAll()
+        as $row) {
+      $canon_map[$row[0]] = $row[1];
+    }
+  }
+
+  $runaway = 25;
+  do {
+    if (isset($canon_map[$username])) {
+      if ($username == $canon_map[$username]) {
+        break;
+      }
+      $username = $canon_map[$username];
+    } elseif (preg_match('/<([a-z0-9_.+=-]+@[a-z0-9.-]+)>/', $username, $M)) {
+      // look at just the email address
+      $username = $M[1];
+      if (!isset($canon_map[$username])) {
+        break;
+      }
+    } else {
+      break;
+    }
+  } while ($runaway-- > 0);
+
+  return $username;
+}
+
+function mtrack_username($username, $options = array())
+{
+  $username = mtrack_canon_username($username);
+  $userdata = MTrackAuth::getUserData($username);
+
+  if (isset($userdata['fullname']) && strlen($userdata['fullname'])) {
+    $title = " title='" .
+        htmlentities($userdata['fullname'], ENT_QUOTES, 'utf-8') . "' ";
+  } else {
+    $title = '';
+  }
+
+  global $ABSWEB;
+
+  if (!isset($options['size'])) {
+    $options['size'] = 24;
+  }
+  if (isset($options['class'])) {
+    $extraclass = " $options[class]";
+  } else {
+    $extraclass = '';
+  }
+
+  if (!ctype_alnum($username)) {
+    $target = "{$ABSWEB}user.php?user=" . urlencode($username);
+    if (isset($options['edit'])) {
+      $target .= '&edit=1';
+    }
+  } else {
+    $target = "{$ABSWEB}user.php/$username";
+    if (isset($options['edit'])) {
+      $target .= '?edit=1';
+    }
+  }
+  $open_a = "<a $title href='$target' class='userlink$extraclass'>";
+
+  $ret = '';
+  if ((!isset($options['no_image']) || !$options['no_image'])) {
+    $ret .= $open_a .
+            mtrack_avatar($username, $options['size']) .
+            '</a> ';
+  }
+  if (!isset($options['no_name']) || !$options['no_name']) {
+    $dispuser = $username;
+
+    if (strlen($dispuser) > 12) {
+      if (preg_match("/^([^+]*)(\+.*)?@(.*)$/", $dispuser, $M)) {
+        /* looks like an email address, try to shorten it in a reasonable way */
+        $local = $M[1];
+        $extra = $M[2];
+        $domain = $M[3];
+
+        if (strlen($extra)) {
+          $local .= '...';
+        }
+
+        $dispuser = "$local@$domain";
+      }
+    }
+    $ret .= "$open_a$dispuser</a>";
+  }
+  return $ret;
+}
+
+function mtrack_avatar($username, $size = 24)
+{
+  global $ABSWEB;
+
+  $id = urlencode($username);
+
+  return "<img class='gravatar' width='$size' height='$size' src='{$ABSWEB}avatar.php?u=$id&amp;s=$size'>";
+}
+
+function mtrack_gravatar($email, $size = 24)
+{
+  // d=identicon
+  // d=monsterid
+  // d=wavatar
+  return "<img class='gravatar' width='$size' height='$size' src='http://www.gravatar.com/avatar/" .  md5(strtolower($email)) . "?s=$size&amp;d=wavatar'>";
+}
+
+function mtrack_defrepo()
+{
+  static $defrepo = null;
+  if ($defrepo === null) {
+    $defrepo = MTrackConfig::get('core', 'default.repo');
+    if ($defrepo === null) {
+      $defrepo = '';
+      foreach (MTrackDB::q(
+          'select parent, shortname from repos order by shortname')
+          ->fetchAll() as $row) {
+        $defrepo = MTrackSCM::makeDisplayName($row);
+        break;
+      }
+    } else if (strpos($defrepo, '/') === false) {
+      $defrepo = 'default/' . $defrepo;
+    }
+  }
+  return $defrepo;
+}
+
+function mtrack_changeset_url($cs, $repo = null)
+{
+  global $ABSWEB;
+  if ($repo instanceof MTrackRepo) {
+    $p = $repo->getBrowseRootName() . '/';
+  } elseif ($repo !== null) {
+    if (strpos($repo, '/') === false) {
+      $repo = "default/$repo";
+    }
+    $p = $repo . '/';
+  } else {
+    static $repos = null;
+    if ($repos === null) {
+      $repos = array();
+      foreach (MTrackDB::q('select r.shortname as repo, p.shortname as proj from repos r left join project_repo_link l using (repoid) left join projects p using (projid) where parent is null or length(parent) = 0')->fetchAll(PDO::FETCH_ASSOC) as $row) {
+        $r = $row['repo'];
+        if ($row['proj']) {
+          $repos[$row['proj']] = $r;
+        }
+        $repos[$row['repo']] = $r;
+      }
+    }
+    $p = null;
+    foreach ($repos as $a => $b) {
+      if (!strncasecmp($cs, $a, strlen($a))) {
+        $p = 'default/' . $b;
+        $cs = substr($cs, strlen($a));
+        break;
+      }
+    }
+    if ($p === null) {
+      $p = mtrack_defrepo();
+    }
+    $p .= '/';
+  }
+  return $ABSWEB . "changeset.php/$p$cs";
+}
+
+function mtrack_changeset($cs, $repo = null)
+{
+  $display = $cs;
+  if (strlen($display) > 12) {
+    $display = substr($display, 0, 12);
+  }
+  $url = mtrack_changeset_url($cs, $repo);
+  return "<a class='changesetlink' href='$url'>[$display]</a>";
+}
+
+function mtrack_branch($branch, $repo = null)
+{
+  return "<span class='branchname'>$branch</span>";
+}
+
+function mtrack_wiki($pagename, $extras = array())
+{
+  global $ABSWEB;
+  if ($pagename instanceof MTrackWikiItem) {
+    $wiki = $pagename;
+  } else if (is_string($pagename)) {
+    $wiki = null;//MTrackWikiItem::loadByPageName($pagename);
+  } else {
+    // FIXME: hinted data from reports
+    throw new Exception("FIXME: wiki");
+  }
+  if ($wiki) {
+    $pagename = $wiki->pagename;
+  }
+  $html = "<a class='wikilink'";
+  if (isset($extras['#'])) {
+    $anchor = '#' . $extras['#'];
+  } else {
+    $anchor = '';
+  }
+  $html .= " href=\"{$ABSWEB}wiki.php/$pagename$anchor\">";
+  if (isset($extras['display'])) {
+    $html .= htmlentities($extras['display'], ENT_QUOTES, 'utf-8');
+  } else {
+    $html .= htmlentities($pagename, ENT_QUOTES, 'utf-8');
+  }
+  $html .= "</a>";
+  return $html;
+}
+
+function mtrack_ticket($no, $extras = array())
+{
+  global $ABSWEB;
+
+  if ($no instanceof MTrackIssue) {
+    $tkt = $no;
+  } else if (is_string($no) || is_int($no)) {
+    static $cache = array();
+
+    if ($no[0] == '#') {
+      $no = substr($no, 1);
+    }
+
+    if (!isset($cache[$no])) {
+      $tkt = MTrackIssue::loadByNSIdent($no);
+      if (!$tkt) {
+        $tkt = MTrackIssue::loadById($no);
+      }
+      $cache[$no] = $tkt;
+    } else {
+      $tkt = $cache[$no];
+    }
+  } else {
+    // FIXME: hinted data from reports
+    $tkt = new stdClass;
+    $tkt->tid = $no['ticket'];
+    $tkt->summary = $no['summary'];
+    if (isset($no['state'])) {
+      $tkt->status = $no['state'];
+    } elseif (isset($no['status'])) {
+      $tkt->status = $no['status'];
+    } elseif (isset($no['__status__'])) {
+      $tkt->status = $no['__status__'];
+    } else {
+      $tkt->status = '';
+    }
+  }
+  if ($tkt == NULL) {
+    $tkt = new stdClass;
+    $tkt->tid = $no;
+    $tkt->summary = 'No such ticket';
+    $tkt->status = 'No such ticket';
+  }
+  $html = "<a class='ticketlink";
+  if ($tkt->status == 'closed') {
+    $html .= ' completed';
+  }
+  if (!empty($tkt->nsident)) {
+    $ident = $tkt->nsident;
+  } else {
+    $ident = $tkt->tid;
+  }
+  if (isset($extras['#'])) {
+    $anchor = '#' . $extras['#'];
+  } else {
+    $anchor = '';
+  }
+  $html .= "' href=\"{$ABSWEB}ticket.php/$ident$anchor\">";
+  if (isset($extras['display'])) {
+    $html .= htmlentities($extras['display'], ENT_QUOTES, 'utf-8');
+  } else {
+    $html .= '#' . htmlentities($ident, ENT_QUOTES, 'utf-8');
+  }
+  $html .= "</a>";
+  return $html;
+}
+
+function mtrack_tag($tag, $repo = null)
+{
+  return "<span class='tagname'>$tag</span>";
+}
+
+function mtrack_keyword($keyword)
+{
+  global $ABSWEB;
+  $kw = urlencode($keyword);
+  return "<a class='keyword' href='{$ABSWEB}search.php?q=keyword%3A$kw'>$keyword</span>";
+}
+
+function mtrack_multi_select_box($name, $title, $items, $values = null)
+{
+  $title = htmlentities($title, ENT_QUOTES, 'utf-8');
+  $html = "<select id='$name' name='{$name}[]' multiple='multiple' title='$title'>";
+  foreach ($items as $k => $v) {
+    $html .= "<option value='" .
+      htmlspecialchars($k, ENT_QUOTES, 'utf-8') .
+      "'";
+    if (isset($values[$k])) {
+      $html .= ' selected';
+    }
+    $html .= ">" . htmlentities($v, ENT_QUOTES, 'utf-8') . "</option>\n";
+  }
+  return $html . "</select>";
+}
+
+function mtrack_select_box($name, $items, $value = null, $keyed = true)
+{
+  $html = "<select id='$name' name='$name'>";
+  foreach ($items as $k => $v) {
+    $html .= "<option value='" .
+      htmlspecialchars($k, ENT_QUOTES, 'utf-8') .
+      "'";
+    if (($keyed && $value == $k) || (!$keyed && $value == $v)) {
+      $html .= ' selected';
+    }
+    $html .= ">" . htmlentities($v, ENT_QUOTES, 'utf-8') . "</option>\n";
+  }
+  return $html . "</select>";
+}
+
+function mtrack_radio($name, $value, $curval)
+{
+  $checked = $curval == $value ? " checked='checked'": '';
+  return "<input type='radio' id='$value' name='$name' value='$value'$checked>";
+}
+
+function mtrack_diff($diffstr)
+{
+  $nlines = 0;
+
+  if (is_resource($diffstr)) {
+    $lines = array();
+    while (($line = fgets($diffstr)) !== false) {
+      $lines[] = rtrim($line, "\r\n");
+    }
+    $diffstr = $lines;
+  }
+
+  if (is_string($diffstr)) {
+    $abase = md5($diffstr);
+    $diffstr = preg_split("/\r?\n/", $diffstr);
+  } else {
+    $abase = md5(join("\n", $diffstr));
+  }
+
+  /* we could use toggle() below, but it is much faster to determine
+   * if we are hiding or showing based on a single variable than evaluating
+   * that for each possible cell */
+  $html = <<<HTML
+<button class='togglediffcopy' type='button'>Toggle Diff Line Numbers</button>
+HTML;
+  $html .= "<table class='code diff'>";
+  //$html = "<pre class='code diff'>";
+
+  while (true) {
+    if (!count($diffstr)) {
+      break;
+    }
+    $line = array_shift($diffstr);
+    $nlines++;
+    if (!strncmp($line, '@@ ', 3)) {
+      /* done with preamble */
+      break;
+    }
+    $line = htmlspecialchars($line, ENT_QUOTES, 'utf-8');
+    $line = "<tr class='meta'><td class='lineno'></td><td class='lineno'></td><td class='lineno'></td><td width='100%'>$line</tr>";
+    $html .= $line . "\n";
+  }
+
+  $lines = array(0, 0);
+  $first = false;
+  while (true) {
+    $class = 'unmod';
+
+    if (preg_match("/^@@\s+-(\pN+)(?:,\pN+)?\s+\+(\pN+)(?:,\pN+)?\s*@@/",
+        $line, $M)) {
+      $lines[0] = (int)$M[1] - 1;
+      $lines[1] = (int)$M[2] - 1;
+      $class = 'meta';
+      $first = true;
+    } elseif (strlen($line)) {
+      if ($line[0] == '-') {
+        $lines[0]++;
+        $class = 'removed';
+      } elseif ($line[0] == '+') {
+        $lines[1]++;
+        $class = 'added';
+      } else {
+        $lines[0]++;
+        $lines[1]++;
+      }
+    } else {
+      $lines[0]++;
+      $lines[1]++;
+    }
+    $row = "<tr class='$class";
+    if ($first) {
+      $row .= ' first';
+    }
+    if ($class != 'meta' && $first) {
+      $first = false;
+    }
+    $row .= "'>";
+
+    switch ($class) {
+      case 'meta':
+        $line_info = '';
+        $row .= "<td class='lineno'></td><td class='lineno'></td>";
+        break;
+      case 'added':
+        $row .= "<td class='lineno'></td><td class='lineno'>" . $lines[1] . "</td>";
+        break;
+      case 'removed':
+        $row .= "<td class='lineno'>" . $lines[0] . "</td><td class='lineno'></td>";
+        break;
+      default:
+        $row .= "<td class='lineno'>" . $lines[0] . "</td><td class='lineno'>" . $lines[1] . "</td>";
+    }
+    $anchor = $abase . '.' . $nlines;
+    $row .= "<td class='linelink'><a name='$anchor'></a><a href='#$anchor' title='link to this line'>#</a></td>";
+
+    $line = htmlspecialchars($line, ENT_QUOTES, 'utf-8');
+    $row .= "<td class='line' width='100%'>$line</td></tr>\n";
+    $html .= $row;
+
+    if (!count($diffstr)) {
+      break;
+    }
+    $line = array_shift($diffstr);
+    $nlines++;
+  }
+
+  if ($nlines == 0) {
+    return null;
+  }
+
+  $html .= "</table>";
+  return $html;
+}
+
+function mtrack_mime_detect($filename, $namehint = null)
+{
+  /* does config tell us how to decide mimetype */
+  $detector = MTrackConfig::get('core', 'mimetype_detect');
+
+  /* if detector is blank, we'll try to figure out which one to use */
+  if (empty($detector)) {
+    if (function_exists('finfo_open')) {
+      $detector = 'fileinfo';
+    } elseif (function_exists('mime_content_type')) {
+      $detector = 'mime_magic';
+    } else {
+      $detector = 'file';
+    }
+  }
+
+  /* use detector or all mimetypes will be blank */
+  if ($detector === 'fileinfo') {
+    if (defined('FILEINFO_MIME_TYPE')) {
+      $fileinfo = finfo_open(FILEINFO_MIME_TYPE);
+    } else {
+      $magic = MTrackConfig::get('core', 'mime.magic');
+      if (strlen($magic)) {
+        $fileinfo = finfo_open(FILEINFO_MIME, $magic);
+      } else {
+        $fileinfo = finfo_open(FILEINFO_MIME);
+      }
+    }
+    $mimetype = finfo_file($fileinfo, $filename);
+    finfo_close($fileinfo);
+  } elseif ($detector === 'mime_magic') {
+    $mimetype = mime_content_type($filename);
+  } elseif (PHP_OS != 'SunOS') {
+    $mimetype = shell_exec("file -b --mime " . escapeshellarg($filename));
+  } else {
+    $mimetype = 'application/octet-stream';
+  }
+  $mimetype = trim(preg_replace("/\s*;.*$/", '', $mimetype));
+  if (empty($mimetype)) {
+    $mimetype = 'application/octet-stream';
+  }
+  if ($mimetype == 'application/octet-stream') {
+    if ($namehint === null) {
+      $namehint = $filename;
+    }
+    $pi = pathinfo($namehint);
+    switch (strtolower($pi['extension'])) {
+      case 'bin': return 'application/octet-stream';
+      case 'exe': return 'application/octet-stream';
+      case 'dll': return 'application/octet-stream';
+      case 'iso': return 'application/octet-stream';
+      case 'so': return 'application/octet-stream';
+      case 'a': return 'application/octet-stream';
+      case 'lib': return 'application/octet-stream';
+      case 'pdf': return 'application/pdf';
+      case 'ps': return 'application/postscript';
+      case 'ai': return 'application/postscript';
+      case 'eps': return 'application/postscript';
+      case 'ppt': return 'application/vnd.ms-powerpoint';
+      case 'xls': return 'application/vnd.ms-excel';
+      case 'tiff': return 'image/tiff';
+      case 'tif': return 'image/tiff';
+      case 'wbmp': return 'image/vnd.wap.wbmp';
+      case 'png': return 'image/png';
+      case 'gif': return 'image/gif';
+      case 'jpg': return 'image/jpeg';
+      case 'jpeg': return 'image/jpeg';
+      case 'ico': return 'image/x-icon';
+      case 'bmp': return 'image/bmp';
+      case 'css': return 'text/css';
+      case 'htm': return 'text/html';
+      case 'html': return 'text/html';
+      case 'txt': return 'text/plain';
+      case 'xml': return 'text/xml';
+      case 'eml': return 'message/rfc822';
+      case 'asc': return 'text/plain';
+      case 'rtf': return 'application/rtf';
+      case 'wml': return 'text/vnd.wap.wml';
+      case 'wmls': return 'text/vnd.wap.wmlscript';
+      case 'gtar': return 'application/x-gtar';
+      case 'gz': return 'application/x-gzip';
+      case 'tgz': return 'application/x-gzip';
+      case 'tar': return 'application/x-tar';
+      case 'zip': return 'application/zip';
+      case 'sql': return 'text/plain';
+    }
+    // if the file is ascii, then treat it as text/plain
+    $fp = fopen($filename, 'rb');
+    $mimetype = 'text/plain';
+    do {
+      $x = fread($fp, 8192);
+      if (!strlen($x)) break;
+      if (preg_match('/([\x80-\xff])/', $x, $M)) {
+        $mimetype = 'application/octet-stream';
+        break;
+      }
+    } while (true);
+    $fp = null;
+  }
+  return $mimetype;
+}
+
+function mtrack_run_tool($toolname, $mode, $args = null)
+{
+  global $FORKS;
+
+  $tool = MTrackConfig::get('tools', $toolname);
+  if (!strlen($tool)) {
+    $tool = $toolname;
+  }
+  if (PHP_OS == 'Windows' && strpos($tool, ' ') !== false) {
+    $tool = '"' . $tool . '"';
+  }
+  $cmd = $tool;
+  if (is_array($args)) {
+    foreach ($args as $arg) {
+      if (is_array($arg)) {
+        foreach ($arg as $a) {
+          $cmd .= ' ' . escapeshellarg($a);
+        }
+      } else {
+        $cmd .= ' ' . escapeshellarg($arg);
+      }
+    }
+  }
+  if (!isset($FORKS[$cmd])) {
+    $FORKS[$cmd] = 0;
+  }
+  $FORKS[$cmd]++;
+  if (false) {
+    if (php_sapi_name() == 'cli') {
+      echo $cmd, "\n";
+    } else {
+      error_log($cmd);
+      echo htmlentities($cmd) . "<br>\n";
+    }
+  }
+
+  switch ($mode) {
+    case 'read':   return popen($cmd, 'r');
+    case 'write':  return popen($cmd, 'w');
+    case 'string': return stream_get_contents(popen($cmd, 'r'));
+    case 'proc':
+      $pipedef = array(
+        0 => array('pipe', 'r'),
+        1 => array('pipe', 'w'),
+        2 => array('pipe', 'w'),
+      );
+      $proc = proc_open($cmd, $pipedef, $pipes);
+      return array($proc, $pipes);
+  }
+}
+
+if (php_sapi_name() != 'cli') {
+  set_exception_handler('mtrack_last_chance_saloon');
+  error_reporting(E_NOTICE|E_ERROR|E_WARNING);
+  ini_set('display_errors', false);
+  set_time_limit(300);
+}
+
+
diff --git a/inc/wiki-item.php b/inc/wiki-item.php
new file mode 100644 (file)
index 0000000..58dc531
--- /dev/null
@@ -0,0 +1,176 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackWikiItem {
+  public $pagename = null;
+  public $filename = null;
+  public $version = null;
+  public $file = null;
+  static $wc = null;
+
+  function __get($name) {
+    if ($name == 'content') {
+      $this->content = stream_get_contents($this->file->cat());
+      return $this->content;
+    }
+  }
+
+  static function commitNow() {
+    /* force any delayed push to invoke right now */
+    self::$wc = null;
+    putenv("MTRACK_WIKI_COMMIT=");
+  }
+
+  static function loadByPageName($name) {
+    $w = new MTrackWikiItem($name);
+    if ($w->file) {
+      return $w;
+    }
+    return null;
+  }
+
+  static function getWC() {
+    if (self::$wc === null) {
+      self::getRepoAndRoot($repo);
+      self::$wc = $repo->getWorkingCopy();
+    }
+    return self::$wc;
+  }
+
+  static function getRepoAndRoot(&$repo) {
+    $repo = MTrackRepo::loadByName('default/wiki');
+    return $repo->getDefaultRoot();
+  }
+
+  function __construct($name, $version = null) {
+    $this->pagename = $name;
+    $this->filename = self::getRepoAndRoot($repo) . $name;
+    $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
+    if ($suf) {
+      $this->filename .= $suf;
+    }
+
+    if ($version !== null) {
+      $this->file = $repo->file($this->filename, 'rev', $version);
+    } else {
+      $this->file = $repo->file($this->filename);
+    }
+    if ($this->file && $repo->history($this->filename, 1)) {
+      $this->version = $this->file->rev;
+    } else {
+      $this->file = null;
+    }
+  }
+
+  function save(MTrackChangeset $changeset) {
+    $wc = self::getWC();
+    $lfilename = $this->pagename;
+    $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
+    if ($suf) {
+      $lfilename .= $suf;
+    }
+
+    if (!strlen(trim($this->content))) {
+      if ($wc->file_exists($lfilename)) {
+        // removing
+        $wc->delFile($lfilename);
+      }
+    } else {
+      if (!$wc->file_exists($lfilename)) {
+        // handle dirs
+        $elements = explode('/', $lfilename);
+        $accum = array();
+        while (count($elements) > 1) {
+          $ent = array_shift($elements);
+          $accum[] = $ent;
+          $base = join(DIRECTORY_SEPARATOR, $accum);
+          if (!$wc->file_exists($base)) {
+            if (!mkdir($wc->getDir() . DIRECTORY_SEPARATOR . $base)) {
+              throw new Exception(
+                  "unable to mkdir(" . $wc->getDir() .
+                  DIRECTORY_SEPARATOR . "$base)");
+            }
+            $wc->addFile($base);
+          } else if (!is_dir($wc->getDir() . DIRECTORY_SEPARATOR . $base)) {
+            throw new Exception("$base is not a dir; cannot create $lfilename");
+          }
+        }
+        file_put_contents($wc->getDir() . DIRECTORY_SEPARATOR . $lfilename,
+            $this->content);
+        $wc->addFile($lfilename);
+      } else {
+        file_put_contents($wc->getDir() . DIRECTORY_SEPARATOR . $lfilename,
+            $this->content);
+      }
+    }
+    /* use an env var to signal to the commit hook that it does not
+     * need to make a changeset for this commit */
+    putenv("MTRACK_WIKI_COMMIT=1");
+    $wc->commit($changeset);
+  }
+
+  static function index_item($object)
+  {
+    list($ignore, $ident) = explode(':', $object, 2);
+    $w = MTrackWikiItem::loadByPageName($ident);
+
+    MTrackSearchDB::add("wiki:$w->pagename", array(
+      'wiki' => $w->content,
+      'who' => $w->who,
+      ), true);
+  }
+  static function _get_parent_for_acl($objectid) {
+    if (preg_match("/^(wiki:.*)\/([^\/]+)$/", $objectid, $M)) {
+      return $M[1];
+    }
+    if (preg_match("/^wiki:.*$/", $objectid, $M)) {
+      return 'Wiki';
+    }
+    return null;
+  }
+}
+
+class MTrackWikiCommitListener implements IMTrackCommitListener {
+  function vetoCommit($msg, $files, $actions) {
+    return true;
+  }
+
+  function postCommit($msg, $files, $actions) {
+    /* is this affecting the wiki? */
+    $wiki = array();
+    $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
+    foreach ($files as $name) {
+      list($repo, $fname) = explode('/', $name, 2);
+      if ($repo == 'wiki') {
+        if ($suf && substr($fname, -strlen($suf)) == $suf) {
+          $fname = substr($fname, 0, -strlen($suf));
+        }
+        $wiki[] = $fname;
+      }
+    }
+    /* MTRACK_WIKI_COMMIT is set by MTrackWikiItem when it commits,
+     * so we check for the absence of it to determine if mtrack has
+     * recorded a changeset record */
+    if (count($wiki) && getenv("MTRACK_WIKI_COMMIT") != "1") {
+      /* wiki being changed outside of the MTrackWikiItem class, so
+       * let's create a changeset record for the search engine to
+       * pick up and index this change */
+      foreach ($wiki as $name) {
+        $CS = MTrackChangeset::begin("wiki:$name", $msg);
+        $CS->commit();
+      }
+    }
+    return true;
+  }
+
+  static function register() {
+    $l = new MTrackWikiCommitListener;
+    MTrackCommitChecker::registerListener($l);
+  }
+
+};
+
+MTrackSearchDB::register_indexer('wiki', array('MTrackWikiItem', 'index_item'));
+MTrackWikiCommitListener::register();
+MTrackACL::registerAncestry('wiki', array('MTrackWikiItem', '_get_parent_for_acl'));
+
diff --git a/inc/wiki.php b/inc/wiki.php
new file mode 100644 (file)
index 0000000..6ccf7ae
--- /dev/null
@@ -0,0 +1,1231 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+class MTrackWikiParser {
+
+const EMAIL_LOOKALIKE_PATTERN = 
+"[a-zA-Z0-9.'=+_-]+@(?:[a-zA-Z0-9_-]+\.)+[a-zA-Z](?:[-a-zA-Z\d]*[a-zA-Z\d])?";
+const BOLDITALIC_TOKEN = "'''''";
+const BOLD_TOKEN = "'''";
+const ITALIC_TOKEN = "''";
+const UNDERLINE_TOKEN = "__";
+const STRIKE_TOKEN = "~~";
+const SUBSCRIPT_TOKEN = ",,";
+const SUPERSCRIPT_TOKEN = "\^";
+const INLINE_TOKEN = "`";
+const STARTBLOCK_TOKEN = "\{\{\{";
+const STARTBLOCK = "{{{";
+const ENDBLOCK_TOKEN = "\}\}\}";
+const ENDBLOCK = "}}}";
+const LINK_SCHEME = "[\w.+-]+"; # as per RFC 2396
+const INTERTRAC_SCHEME = "[a-zA-Z.+-]*?"; # no digits (support for shorthand links)
+
+const QUOTED_STRING = "'[^']+'|\"[^\"]+\"";
+
+const SHREF_TARGET_FIRST = "[\w/?!#@](?<!_)"; # we don't want "_"
+const SHREF_TARGET_MIDDLE = "(?:\|(?=[^|\s])|[^|<>\s])";
+const SHREF_TARGET_LAST = "[\w/=](?<!_)"; # we don't want "_"
+
+const LHREF_RELATIVE_TARGET = "[/#][^\s\]]*|\.\.?(?:[/#][^\s\]]*)?";
+
+# See http://www.w3.org/TR/REC-xml/#id 
+const XML_NAME = "[\w:](?<!\d)[\w:.-]*";
+
+const LOWER = '(?<![A-Z0-9_])';
+const UPPER = '(?<![a-z0-9_])';
+
+  static $pre_rules = array(
+    array("(?P<bolditalic>!?%s)", self::BOLDITALIC_TOKEN),
+    array("(?P<bold>!?%s)" , self::BOLD_TOKEN),
+    array("(?P<italic>!?%s)" , self::ITALIC_TOKEN),
+    array("(?P<underline>!?%s)" , self::UNDERLINE_TOKEN),
+    array("(?P<strike>!?%s)" , self::STRIKE_TOKEN),
+    array("(?P<subscript>!?%s)" , self::SUBSCRIPT_TOKEN),
+    array("(?P<superscript>!?%s)" , self::SUPERSCRIPT_TOKEN),
+    array("(?P<inlinecode>!?%s(?P<inline>.*?)%s)" ,
+        self::STARTBLOCK_TOKEN, self::ENDBLOCK_TOKEN),
+    array("(?P<inlinecode2>!?%s(?P<inline2>.*?)%s)",
+        self::INLINE_TOKEN, self::INLINE_TOKEN),
+  );
+  static $post_rules = array(
+    # WikiPageName
+    array("(?P<wikipagename>!?(?<!/)\\b\w%s(?:\w%s)+(?:\w%s(?:\w%s)*[\w/]%s)+(?:@\d+)?(?:#%s)?(?=:(?:\Z|\s)|[^:a-zA-Z]|\s|\Z))",
+      self::UPPER, self::LOWER, self::UPPER, self::LOWER, self::LOWER, self::XML_NAME),
+    # [WikiPageName with label]
+    array("(?P<wikipagenamewithlabel>!?\[\w%s(?:\w%s)+(?:\w%s(?:\w%s)*[\w/]%s)+(?:@\d+)?(?:#%s)?(?=:(?:\Z|\s)|[^:a-zA-Z]|\s|\Z)\s+(?:%s|[^\]]+)\])",
+      self::UPPER, self::LOWER, self::UPPER, self::LOWER, self::LOWER, self::XML_NAME, self::QUOTED_STRING),
+
+    # [21450] changeset
+    "(?P<svnchangeset>!?\[(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+)\])",
+    # #ticket
+    "(?P<ticket>!?#(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+))",
+    # {report}
+    "(?P<report>!?\{([^}]*)\})",
+
+    # e-mails
+    array("(?P<email>!?%s)" , self::EMAIL_LOOKALIKE_PATTERN),
+    # > ...
+    "(?P<citation>^(?P<cdepth>>(?: *>)*))",
+    # &, < and > to &amp;, &lt; and &gt;
+    "(?P<htmlspecialcharsape>[&<>])",
+    # wiki:TracLinks
+    array(
+      "(?P<shref>!?((?P<sns>%s):(?P<stgt>%s|%s(?:%s*%s)?)))",
+      self::LINK_SCHEME, self::QUOTED_STRING,
+      self::SHREF_TARGET_FIRST, self::SHREF_TARGET_MIDDLE,
+      self::SHREF_TARGET_LAST),
+
+    # [wiki:TracLinks with optional label] or [/relative label]
+    array(
+      "(?P<lhref>!?\[(?:(?P<rel>%s)|(?P<lns>%s):(?P<ltgt>%s|[^\]\s]*))(?:\s+(?P<label>%s|[^\]]+))?\])",
+      self::LHREF_RELATIVE_TARGET, self::LINK_SCHEME,
+      self::QUOTED_STRING, self::QUOTED_STRING),
+
+    # [[macro]] call
+    "(?P<macro>!?\[\[(?P<macroname>[\w/+-]+)(\]\]|\((?P<macroargs>.*?)\)\]\]))",
+    # == heading == #hanchor
+    array(
+    "(?P<heading>^\s*(?P<hdepth>=+)\s.*\s(?P=hdepth)\s*(?P<hanchor>#%s)?(?:\s|$))", self::XML_NAME),
+    #  * list
+    "(?P<list>^(?P<ldepth>\s+)(?:[-*]|\d+\.|[a-zA-Z]\.|[ivxIVX]{1,5}\.) )",
+    # definition:: 
+    array(
+      "(?P<definition>^\s+((?:%s[^%s]*%s|%s(?:%s{,2}[^%s])*?%s|[^%s%s:]+|:[^:]+)+::)(?:\s+|$))",
+      self::INLINE_TOKEN, self::INLINE_TOKEN, self::INLINE_TOKEN,
+      self::STARTBLOCK_TOKEN, '}', '}',
+      self::ENDBLOCK_TOKEN, self::INLINE_TOKEN, '{'),
+    # (leading space)
+    "(?P<indent>^(?P<idepth>\s+)(?=\S))",
+    # || table ||
+    "(?P<last_table_cell>\|\|\s*$)",
+    "(?P<table_cell>\|\|)",
+  );
+
+    function get_rules() {
+      $this->prepare_rules();
+      return $this->compiled_rules;
+    }
+
+    private function _build_rule(&$rules, $rule_def) {
+      foreach ($rule_def as $rule) {
+        if (is_array($rule)) {
+          $fmt = array_shift($rule);
+          $rule = vsprintf($fmt, $rule);
+        }
+        $rules[] = $rule;
+      }
+    }
+
+    var $compiled_rules = null;
+
+    function prepare_rules() {
+      if ($this->compiled_rules) {
+        return $this->compiled_rules;
+      }
+      $helpers = array();
+      $syntax = array();
+
+      $this->_build_rule($syntax, self::$pre_rules);
+      $this->_build_rule($syntax, self::$post_rules);
+
+      foreach ($syntax as $rule) {
+        if (preg_match_all("/\?P<([a-z\d_]+)>/", $rule, $matches)) {
+          $helpers[] = $matches[1][0];
+        }
+      }
+      $this->helper_patterns = $helpers;
+
+      /* now compose it into a big regex */
+      $this->compiled_rules = "/" .
+        str_replace("/", "\\/", join('|', $syntax)) .
+        "/u";
+    }
+}
+
+class MTrackWiki {
+  static $macros = array();
+  static $processors = array();
+
+  static function format_to_html($text) {
+    $f = new MTrackWikiHTMLFormatter;
+    $f->format($text);
+    $html = $f->out;
+    if (false) { /* saveHTML messes with the encoding */
+      /* tidy it up */
+      @$d = DOMDocument::loadHTML($html);
+      if ($d) {
+        $d->formatOutput = true;
+        $d->substituteEntities = false;
+        $html = $d->saveHTML();
+        $html = preg_replace("/^.*<body>/sm", '', $html);
+        $html = preg_replace(",</body>.*,sm", '', $html);
+      }
+    }
+    return $html;
+  }
+
+  static function format_to_oneliner($text) {
+    $f = new MTrackWikiOneLinerFormatter;
+    $f->format($text);
+    return $f->out;
+  }
+  static function format_wiki_page($name) {
+    $d = MTrackWikiItem::loadByPageName($name);
+    if ($d) {
+      return self::format_to_html($d->content);
+    }
+    return null;
+  }
+
+  static function register_macro($name, $callback) {
+    self::$macros[$name] = $callback;
+  }
+
+  static function register_processor($name, $callback) {
+    self::$processors[$name] = $callback;
+  }
+
+  static function macro_IncludeWiki($pagename) {
+    return self::format_wiki_page($pagename);
+  }
+  static function macro_IncludeHelp($pagename) {
+    return self::format_to_html(file_get_contents(
+      dirname(__FILE__) . '/../defaults/help/' . basename($pagename)));
+  }
+  static function macro_comment() {
+    return '';
+  }
+  static function processor_comment($name, $content) {
+    return '';
+  }
+  static function processor_html($name, $content) {
+    return join("\n", $content);
+  }
+  static function processor_dataset($name, $content) {
+    $res = '<table class="report wiki dataset">';
+    while (count($content)) {
+      $row = array_shift($content);
+      $next_row = array_shift($content);
+      $cols = preg_split("/\s*\|\s*/", $row);
+      if ($next_row[0] == '-') {
+        // it's a header
+        $res .= '<thead><tr>';
+        foreach ($cols as $c) {
+          $res .= "<th>" . htmlentities($c, ENT_QUOTES, 'utf-8') . "</th>\n";
+        }
+        $res .= "</tr></thead><tbody>";
+      } else {
+        if (is_string($next_row)) {
+          array_unshift($content, $next_row);
+        }
+        // regular row
+        $res .= "<tr>";
+        foreach ($cols as $c) {
+          $res .= "<td>" . htmlentities($c, ENT_QUOTES, 'utf-8') . "</td>\n";
+        }
+        $res .= "</tr>\n";
+      }
+    }
+    $res .= "</tbody></table>\n";
+    return $res;
+  }
+}
+MTrackWiki::register_macro('IncludeWikiPage',
+  array('MTrackWiki', 'macro_IncludeWiki'));
+MTrackWiki::register_macro('IncludeHelpPage',
+  array('MTrackWiki', 'macro_IncludeHelp'));
+MTrackWiki::register_macro('Comment',
+  array('MTrackWiki', 'macro_comment'));
+MTrackWiki::register_processor('comment',
+  array('MTrackWiki', 'processor_comment'));
+MTrackWiki::register_processor('html',
+  array('MTrackWiki', 'processor_html'));
+MTrackWiki::register_processor('dataset',
+  array('MTrackWiki', 'processor_dataset'));
+
+class MTrackWikiHTMLFormatter {
+  var $parser;
+  var $out;
+  var $in_table_row;
+  var $table_row_count = 0;
+  var $open_tags;
+  var $list_stack;
+  var $quote_stack;
+  var $tabstops;
+  var $in_code_block;
+  var $in_table;
+  var $in_def_list;
+  var $in_table_cell;
+  var $paragraph_open;
+
+  function __construct() {
+    $this->parser = new MTrackWikiParser;
+  }
+
+  function reset() {
+    $this->open_tags = array();
+    $this->list_stack = array();
+    $this->quote_stack = array();
+    $this->tabstops = array();
+    $this->in_code_block = 0;
+    $this->in_table = false;
+    $this->in_def_list = false;
+    $this->in_table_cell = false;
+    $this->paragraph_open = false;
+  }
+
+  function _apply_rules($line) {
+    $rules = $this->parser->get_rules();
+    /* slightly tricky bit of code here, because preg_replace_callback
+     * doesn't seem to support named groups */
+    $matches = array();
+    if (preg_match_all($rules, $line, $matches, PREG_OFFSET_CAPTURE)) {
+      $repl = array();
+      foreach ($matches as $key => $info) {
+        if (is_string($key)) {
+          foreach ($info as $nmatch => $item) {
+            if (!is_array($item)) {
+              continue;
+            }
+            $match = $item[0];
+            $offset = $item[1];
+
+            if (strlen($match) && $offset >= 0) {
+              if ($match[0] == '!') {
+                $repl[$offset] = array(null, $match, null);
+              } else {
+                $func = '_' . $key . '_formatter';
+                if (method_exists($this, $func)) {
+                  $repl[$offset] = array($func, $match, $nmatch);
+                } else {
+                  @$this->missing[$func]++;
+                }
+              }
+            }
+          }
+        }
+      }
+      if (count($repl)) {
+        /* order matches by match offset */
+        ksort($repl);
+        /* and now we can generate for each fragment */
+        $sol = 0;
+        foreach ($repl as $offset => $bits) {
+          list($func, $match, $nmatch) = $bits;
+
+          if ($offset > $sol) {
+            /* emit verbatim */
+            //              $this->out .= "Copying from $sol to $offset\n";
+            $this->out .= substr($line, $sol, $offset - $sol);
+          }
+
+          if ($func === null) {
+            $this->out .= htmlspecialchars(substr($match, 1),
+                            ENT_COMPAT, 'utf-8');
+          } else {
+            //              $this->out .= "invoking $func on $match of len " . strlen($match) . "\n";
+            //              $this->out .= var_export($matches, true) . "\n";
+            $this->$func($match, $matches, $nmatch);
+          }
+
+          $sol = $offset + strlen($match);
+        }
+        $this->out .= substr($line, $sol);
+        $result = '';
+      } else {
+        $result = $line;
+      }
+    } else {
+      $result = $line;
+    }
+    return $result;
+  }
+
+  function format($text, $escape_newlines = false) {
+    $this->out = '';
+    $this->reset();
+    foreach (preg_split("!\r?\n!", $text) as $line) {
+      if ($this->in_code_block || trim($line) == MTrackWikiParser::STARTBLOCK) {
+        $this->handle_code_block($line);
+        continue;
+      }
+      if (!strncmp($line, "----", 4)) {
+        $this->close_table();
+        $this->close_paragraph();
+        $this->close_indentation();
+        $this->close_list();
+        $this->close_def_list();
+        $this->out .= "<hr />\n";
+        continue;
+      }
+      if (strlen($line) == 0) {
+        $this->close_paragraph();
+        $this->close_indentation();
+        $this->close_list();
+        $this->close_def_list();
+        $this->close_table();
+        continue;
+      }
+      if (strncmp($line, "||", 2)) {
+        // Doesn't look like a valid table row line, so break any || in the line
+        $line = str_replace("||", "|", $line);
+      }
+      // Tag expansion and clear tabstops if no indent
+      $line = str_replace("\t", "        ", $line);
+      if ($line[0] != ' ') {
+        $this->tabstops = array();
+      }
+
+      $this->in_list_item = false;
+      $this->in_quote = false;
+
+      $save = $this->out;
+      $this->out = '';
+      $result = $this->_apply_rules($line);
+      $newbit = $this->out;
+      $this->out = $save;
+      if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
+        $this->open_paragraph();
+      }
+
+      if (!$this->in_list_item) {
+        $this->close_list();
+      }
+      if (!$this->in_quote) {
+        $this->close_indentation();
+      }
+      if ($this->in_def_list && $line[0] != ' ') {
+        $this->close_def_list();
+      }
+      if ($this->in_table && strncmp(ltrim($line), '||', 2)) {
+        $this->close_table();
+      }
+      $this->out .= $newbit;
+      $sep = "\n";
+      if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
+        if (strlen($result)) {
+          $this->open_paragraph();
+        }
+        if ($escape_newlines && !preg_match(",<br />\s*$,", $line)) {
+          $sep = "<br />\n";
+        }
+      }
+      $this->out .= $result . $sep;
+      $this->close_table_row();
+    }
+    $this->close_table();
+    $this->close_paragraph();
+    $this->close_indentation();
+    $this->close_list();
+    $this->close_def_list();
+    $this->close_code_blocks();
+  }
+
+  function _parse_heading($match, $info, $nmatch, $shorten) {
+    $match = trim($match);
+    $depth = min(strlen($info['hdepth'][$nmatch][0]), 5);
+    if (isset($info['hanchor']) && is_array($info['hanchor'])
+        && is_array($info['hanchor'][$nmatch])
+        && strlen($info['hanchor'][$nmatch][0])) {
+      $anchor = $info['hanchor'][$nmatch][0];
+    } else {
+      $anchor = '';
+    }
+    $heading_text = substr($match, $depth+1, - $depth - 1 - strlen($anchor));
+    $heading = MTrackWiki::format_to_oneliner($heading_text);
+    if ($anchor) {
+      $anchor = substr($anchor, 1);
+    } else {
+      $anchor = preg_replace("/[^\w:.-]+/", "", $heading_text);
+      if (ctype_digit($anchor[0])) {
+        $anchor = 'a' . $anchor;
+      }
+    }
+    return array($depth, $heading, $anchor);
+  }
+
+  function _heading_formatter($match, $info, $nmatch) {
+    $this->close_table();
+    $this->close_paragraph();
+    $this->close_indentation();
+    $this->close_list();
+    $this->close_def_list();
+    list($depth, $heading, $anchor) = 
+      $this->_parse_heading($match, $info, $nmatch, false);
+    
+    $this->out .= sprintf('<h%d id="%s"><a class="wiki" name="%s">%s</a></h%d>',
+      $depth, $anchor, $anchor, $heading, $depth);
+  }
+
+  function tag_open_p($tag) {
+    /* do we currently have any open tag with $tag as end-tag? */
+    return in_array($tag, $this->open_tags);
+  }
+
+  function open_tag($open_tag, $close_tag) {
+    $this->open_tags[] = array($open_tag, $close_tag);
+  }
+
+  function simple_tag_handler($match, $open_tag, $close_tag) {
+    if ($this->tag_open_p(array($open_tag, $close_tag))) {
+      $this->out .= $this->close_tag($close_tag);
+      return;
+    }
+    $this->open_tag($open_tag, $close_tag);
+    $this->out .= $open_tag;
+  }
+
+  function close_tag($tag) {
+    $tmp = '';
+    /* walk backwards until we find the tag, closing out
+     * as we go */
+    $keys = array_reverse(array_keys($this->open_tags));
+    foreach ($keys as $k) {
+      $pair = $this->open_tags[$k];
+      $tmp .= $pair[1];
+      if ($pair[1] == $tag) {
+        unset($this->open_tags[$k]);
+        foreach ($this->open_tags as $k2 => $pair) {
+          if ($k2 == $k) {
+            break;
+          }
+          $tmp .= $pair[0];
+        }
+        break;
+      }
+    }
+    return $tmp;
+  }
+
+  function _bolditalic_formatter($match, $info) {
+    $italic = array('<i>', '</i>');
+    $open = $this->tag_open_p($italic);
+    $tmp = '';
+    if ($open) {
+      $this->out .= $italic[1];
+      $this->close_tag($italic[1]);
+    }
+    $this->_bold_formatter($match, $info);
+    if (!$open) {
+      $this->out .= $italic[0];
+      $this->open_tag($italic[0], $italic[1]);
+    }
+  }
+
+  function _bold_formatter($match, $info) {
+    $this->simple_tag_handler($match, '<strong>', '</strong>');
+  }
+  function _italic_formatter($match, $info) {
+    $this->simple_tag_handler($match, '<i>', '</i>');
+  }
+  function _underline_formatter($match, $info) {
+    $this->simple_tag_handler($match,
+      '<span class="underline">', '</span>');
+  }
+  function _strike_formatter($match, $info) {
+    $this->simple_tag_handler($match, '<del>', '</del>');
+  }
+  function _subscript_formatter($match, $info) {
+    $this->simple_tag_handler($match, '<sub>', '</sub>');
+  }
+  function _superscript_formatter($match, $info) {
+    $this->simple_tag_handler($match, '<sup>', '</sup>');
+  }
+
+  function _email_formatter($match, $info) {
+    $this->out .= "<a href=\"mailto:" . 
+      htmlspecialchars($match, ENT_QUOTES, 'utf-8') .
+      "\">" . htmlspecialchars($match, ENT_COMPAT, 'utf-8') . "</a>";
+  }
+
+  function _htmlspecialcharsape_formatter($match, $info) {
+    $this->out .= htmlspecialchars($match, ENT_QUOTES, 'utf-8');
+  }
+
+  function _make_link($ns, $target, $match, $label) {
+    global $ABSWEB;
+    $is_closed = false;
+
+    if ($label[0] == '"' || $label[0] == "'") {
+      $label = substr($label, 1, -1);
+    }
+    if (preg_match('/^(.*)#(.*)$/', $target, $M)) {
+      $target = $M[1];
+      $anchor = $M[2];
+    } else {
+      $anchor = null;
+    }
+
+    if (strlen($ns)) {
+
+      /* special cases */
+      if ($ns == 'ticket' && 
+          (strpos($target, '-') !== false || strpos($target, ',') !== false)) {
+        /* ranged ticket query */
+        $ns = 'query';
+        $target = 'id=' . $target;
+      }
+
+      switch ($ns) {
+        case 'ticket':
+          $this->out .= mtrack_ticket($target, array(
+            'display' => $label,
+            '#' => $anchor,
+            ));
+          return;
+
+        case 'changeset':
+          if (strpos($target, ',') !== false) {
+            list($repo, $cs) = explode(',', $target, 2);
+            $this->out .= mtrack_changeset($cs, $repo);
+          } else {
+            $this->out .= mtrack_changeset($target);
+          }
+          return;
+
+        case 'milestone':
+          $label = htmlspecialchars(urldecode($target), ENT_QUOTES, 'utf-8');
+          $target = $ABSWEB . "$ns.php/" . $target;
+
+          $this->out .= "<span class='milestone";
+          $ms = MTrackMilestone::loadByName($target);
+          if ($ms->deleted || $ms->completed) {
+            $this->out .= " completed";
+          }
+          $this->out .= "'><a href=\"$target\">$label</a></span>";
+          return;
+
+        case 'wiki':
+          $this->out .= mtrack_wiki($target, array(
+            '#' => $anchor,
+            'display' => $label
+            ));
+          return;
+
+        case 'help':
+          if (!empty($anchor)) {
+            $target .= "#$anchor";
+          }
+          $this->out .= 
+            "<a class='wikilink' href='{$ABSWEB}help.php/$target'>$label</a>";
+          return;
+
+        case 'user':
+          $this->out .= mtrack_username($target);
+          return;
+
+        case 'repo':
+          $target = $ABSWEB . "browse.php/$target";
+          break;
+         
+        case 'log':
+          if ($target == '/') {
+            $target = mtrack_defrepo();
+          }
+          $target = $ABSWEB . "$ns.php/$target";
+          break;
+
+        case 'query':
+        case 'report':
+          $target = $ABSWEB . "$ns.php/$target";
+          break;
+        case 'source':
+          @list($file, $rev) = explode('#', $target, 2);
+          $file = ltrim($file, '/');
+          /* some legacy handling here; there are three cases:
+           * owner/repo/path -> repo = owner/repo
+           * repo/path       -> repo = default/repo
+           * path            -> repo = config.ini default repo
+           */
+          $bits = explode('/', $file);
+          $repo = null;
+          if (count($bits) > 2) {
+            /* maybe owner/repo */
+            $repo = MTrackRepo::loadByName($bits[0] . '/' . $bits[1]);
+            if ($repo) {
+              $repo = $repo->getBrowseRootName();
+            }
+          }
+          if ($repo === null && count($bits) > 1) {
+            $repo = MTrackRepo::loadByName('default/' . $bits[0]);
+            if ($repo) {
+              $repo = $repo->getBrowseRootName();
+              array_unshift($bits, 'default');
+            }
+          }
+          if ($repo === null) {
+            $defrep = mtrack_defrepo();
+            if ($defrep) {
+              if (strpos($defrep, '/') === false) {
+                $defrep = "default/$defrep";
+              }
+              $repo = MTrackRepo::loadByName($defrep);
+              if ($repo) {
+                $repo = $repo->getBrowseRootName();
+                array_unshift($bits, $repo);
+              }
+            }
+          }
+          $file = join($bits, '/');
+
+          if ($rev) {
+            $target = $ABSWEB . "file.php/$file@$rev";
+          } else {
+            $target = $ABSWEB . "file.php/$file";
+          }
+          break;
+        case 'comment':
+          if (preg_match('/^(\d+):ticket:(.*)$/', $target, $M)) {
+            $this->out .= mtrack_ticket($M[2], 
+              array(
+                '#' => 'comment:' . $M[1],
+                'display' => $label
+              )
+            );
+            return;
+          } else {
+            $target = "#comment:$target";
+          }
+          break;
+
+        default:
+          $target = "$ns:$target";
+          if (strlen($anchor)) {
+            $target .= "#$anchor";
+          }
+          break;
+      }
+    }
+    $label = htmlspecialchars($label, ENT_QUOTES, 'utf-8');
+    if ($is_closed) {
+      $label = "<del>$label</del>";
+    }
+    $link = "<a href=\"$target\">$label</a>";
+    $this->out .= $link;
+  }
+
+  function _ticket_formatter($match, $info, $nmatch) {
+    $ticket = substr($match, 1);
+    $this->_make_link('ticket', $ticket, $ticket, $match);
+  }
+
+  function _report_formatter($match, $info, $nmatch) {
+    $ticket = substr($match, 1, -1);
+    $this->_make_link('report', $ticket, $ticket, $match);
+  }
+
+  function _svnchangeset_formatter($match, $info, $nmatch) {
+    $rev = substr($match, 1, -1);
+    $this->_make_link('changeset', $rev, $rev, $match);
+  }
+
+  function _wikipagename_formatter($match, $info, $nmatch) {
+    $this->_make_link('wiki', $match, $match, $match);
+  }
+  function _wikipagenamewithlabel_formatter($match, $info, $nmatch) {
+    $match = substr($match, 1, -1);
+    list($page, $label) = explode(" ", $match, 2);
+    $label = $this->_unquote(trim($label));
+    $this->_make_link('wiki', $page, $match, $label);
+  }
+
+
+  function _shref_formatter($match, $info, $nmatch) {
+    $ns = $info['sns'][$nmatch][0];
+    $target = $this->_unquote($info['stgt'][$nmatch][0]);
+    $shref = $info['shref'][$nmatch][0];
+    $this->_make_link($ns, $target, $match, $match);
+  }
+
+  function _lhref_formatter($match, $info, $nmatch) {
+    $rel = $info['rel'][$nmatch][0];
+    $ns = $info['lns'][$nmatch][0];
+    $target = $info['ltgt'][$nmatch][0];
+    $label = isset($info['label'][$nmatch][0]) ? $info['label'][$nmatch][0] : '';
+
+//    var_dump($rel, $ns, $target, $label);
+
+    if (!strlen($label)) {
+      /* [http://target] or [wiki:target] */
+      if (strlen($target)) {
+        if (!strncmp($target, "//", 2)) {
+          /* for [http://target], label is http://target */
+          $label = "$ns:$target";
+        } else {
+          /* for [wiki:target], label is target */
+          $label = $target;
+        }
+      } else {
+        /* [search:] */
+        $label = $ns;
+      }
+    } else {
+      $label = $this->_unquote($label);
+    }
+    if (strlen($rel)) {
+      list($path, $query, $frag) = $this->split_link($rel);
+      if (!strncmp($path, '//', 2)) {
+        $path = '/' . ltrim($path, '/');
+      } elseif ($path[0] == '/') {
+        $path = $GLOBALS['ABSWEB'] . substr($path, 1);
+      }
+      $target = $path;
+      if (strlen($query)) {
+        $target .= "?$query";
+      }
+      if (strlen($frag)) {
+        $target .= "#$frag";
+      }
+      $this->out .= "<a href=\"$target\">$label</a>";
+    } else {
+      $this->_make_link($ns, $target, $match, $label);
+    }
+  }
+
+  function _inlinecode_formatter($match, $info, $nmatch) {
+    $this->out .= "<tt>" . 
+      nl2br(htmlspecialchars($info['inline'][$nmatch][0],
+        ENT_COMPAT, 'utf-8')) .
+        "</tt>";
+  }
+  function _inlinecode2_formatter($match, $info, $nmatch) {
+    $this->out .= "<tt>" . 
+      nl2br(htmlspecialchars($info['inline2'][$nmatch][0],
+        ENT_COMPAT, 'utf-8')) .
+        "</tt>";
+  }
+
+  function _macro_formatter($match, $info, $nmatch) {
+    $name = $info['macroname'][$nmatch][0];
+    if (!strcasecmp($name, 'br')) {
+      $this->out .= "<br />";
+      return;
+    }
+    if (isset(MTrackWiki::$macros[$name])) {
+      $args = explode(',', $info['macroargs'][$nmatch][0]);
+      $this->out .= call_user_func_array(MTrackWiki::$macros[$name], $args);
+    } else {
+      $this->out .= "<tt>" . 
+        htmlspecialchars($match, ENT_QUOTES, 'utf-8') . "</tt>";
+    }
+  }
+
+
+  function split_link($target) {
+    @list($query, $frag) = explode('#', $target, 2);
+    @list($target, $query) = explode('?', $query, 2);
+    return array($target, $query, $frag);
+  }
+
+  function _unquote($text) {
+    return preg_replace("/^(['\"])(.*)(\\1)$/", "\\2", $text);
+  }
+
+  function close_list() {
+    $this->_set_list_depth(0, null, null, null);
+  }
+
+  private function _get_list_depth() {
+    // Return the space offset associated to the deepest opened list
+    if (count($this->list_stack)) {
+      $e = end($this->list_stack);
+      return $e[1];
+    }
+    return 0;
+  }
+
+  private function _open_list($depth, $new_type, $list_class, $start) {
+    $this->close_table();
+    $this->close_paragraph();
+    $this->close_indentation();
+    $this->list_stack[] = array($new_type, $depth);
+    $this->_set_tab($depth);
+    if ($list_class) {
+      $list_class = "wikilist $list_class";
+    } else {
+      $list_class = "wikilist";
+    }
+    $class_attr = $list_class ? sprintf(' class="%s"', $list_class) : '';
+    $start_attr = $start ? sprintf(' start="%s"', $start) : '';
+    $this->out .= "<$new_type$class_attr$start_attr><li>";
+  }
+  private function _close_list($type) {
+    array_pop($this->list_stack);
+    $this->out .= "</li></$type>";
+  }
+
+  private function _set_list_depth($depth, $new_type, $list_class, $start) {
+    if ($depth > $this->_get_list_depth()) {
+      $this->_open_list($depth, $new_type, $list_class, $start);
+      return;
+    }
+    while (count($this->list_stack)) {
+      list($deepest_type, $deepest_offset) = end($this->list_stack);
+      if ($depth >= $deepest_offset) {
+        break;
+      }
+      $this->_close_list($deepest_type);
+    }
+    if ($depth > 0) {
+      if (count($this->list_stack)) {
+        list($old_type, $old_offset) = end($this->list_stack);
+        if ($new_type && $new_type != $old_type) {
+          $this->_close_list($old_type);
+          $this->_open_list($depth, $new_type, $list_class, $start);
+        } else {
+          if ($old_offset != $depth) {
+            array_pop($this->list_stack);
+            $this->list_stack[] = array($old_type, $depth);
+          }
+          $this->out .= "</li><li>";
+        }
+      } else {
+        $this->_open_list($depth, $new_type, $list_class, $start);
+      }
+    }
+  }
+
+  function close_indentation() {
+    $this->_set_quote_depth(0);
+  }
+
+  private function _get_quote_depth() {
+    // Return the space offset associated to the deepest opened quote
+    if (count($this->quote_stack)) {
+      $e = end($this->quote_stack);
+      return $e;
+    }
+    return 0;
+  }
+
+  private function _open_one_quote($d, $citation) {
+    $this->quote_stack[] = $d;
+    $this->_set_tab($d);
+    $class_attr = $citation ? ' class="citation"' : '';
+    $this->out .= "<blockquote$class_attr>\n";
+  }
+
+  private function _open_quote($quote_depth, $depth, $citation) {
+    $this->close_table();
+    $this->close_paragraph();
+    $this->close_list();
+
+    if ($citation) {
+      for ($d = $quote_depth + 1; $d < $depth+1; $d++) {
+        $this->_open_one_quote($d, $citation);
+      }
+    } else {
+      $this->_open_one_quote($depth, $citation);
+    }
+  }
+
+  private function _close_quote() {
+    $this->close_table();
+    $this->close_paragraph();
+    array_pop($this->quote_stack);
+    $this->out .= "</blockquote>\n";
+  }
+
+  private function _set_quote_depth($depth, $citation = false) {
+    $quote_depth = $this->_get_quote_depth();
+    if ($depth > $quote_depth) {
+      $this->_set_tab($depth);
+      $tabstops = $this->tabstops;
+
+      while (count($tabstops)) {
+        $tab = array_pop($tabstops);
+        if ($tab > $quote_depth) {
+          $this->_open_quote($quote_depth, $tab, $citation);
+        }
+      }
+    } else {
+      while ($this->quote_stack) {
+        $deepest_offset = end($this->quote_stack);
+        if ($depth >= $deepest_offset) {
+          break;
+        }
+        $this->_close_quote();
+      }
+      if (!$citation && $depth > 0) {
+        if (count($this->quote_stack)) {
+          $old_offset = end($this->quote_stack);
+          if ($old_offset != $depth) {
+            array_pop($this->quote_stack);
+            $this->quote_stack[] = $depth;
+          }
+        } else {
+          $this->_open_quote($quote_depth, $depth, $citation);
+        }
+      }
+    }
+    if ($depth > 0) {
+      $this->in_quote = true;
+    }
+  }
+
+  function open_paragraph() {
+    if (!$this->paragraph_open) {
+      $this->out .= "<p>\n";
+      $this->paragraph_open = true;
+    }
+  }
+
+  function close_paragraph() {
+    if ($this->paragraph_open) {
+      while (count($this->open_tags)) {
+        $t = array_pop($this->open_tags);
+        $this->out .= $t[1];
+      }
+      $this->out .= "</p>\n";
+      $this->paragraph_open = false;
+    }
+  }
+
+  function _last_table_cell_formatter($match, $info, $nmatch) {
+    return;
+  }
+
+  function _table_cell_formatter($match, $info, $nmatch) {
+    $this->open_table();
+    $this->open_table_row();
+    $tag = $this->table_row_count == 1 ? 'th' : 'td';
+    if ($this->in_table_cell) {
+      $this->out .= "</$tag><$tag>";
+      return;
+    }
+    $this->in_table_cell = 1;
+    $this->out .= "<$tag>";
+  }
+
+
+  function open_table() {
+    if (!$this->in_table) {
+      $this->close_paragraph();
+      $this->close_list();
+      $this->close_def_list();
+      $this->in_table = 1;
+      $this->table_row_count = 0;
+      $this->out .= "<table class='report wiki'>\n";
+    }
+  }
+
+  function open_table_row() {
+    if (!$this->in_table_row) {
+      $this->open_table();
+      if ($this->table_row_count == 0) {
+        $this->out .= "<thead><tr>";
+      } else if ($this->table_row_count == 1) {
+        $this->out .= "<tbody><tr>";
+      } else {
+        $this->out .= "<tr>";
+      }
+      $this->in_table_row = 1;
+      $this->table_row_count++;
+    }
+  }
+
+  function close_table_row() {
+    if ($this->in_table_row) {
+      $tag = $this->table_row_count == 1 ? 'th' : 'td';
+      $this->in_table_row = 0;
+      if ($this->in_table_cell) {
+        $this->in_table_cell = 0;
+        $this->out .= "</$tag>";
+      }
+      if ($this->table_row_count == 1) {
+        $this->out .= "</tr></thead>";
+      } else {
+        $this->out .= "</tr>";
+      }
+    }
+  }
+
+  function close_table() {
+    if ($this->in_table) {
+      $this->close_table_row();
+      if ($this->table_row_count == 1) {
+        $this->out .= "</thead></table>\n";
+      } else {
+        $this->out .= "</tbody></table>\n";
+      }
+      $this->in_table = 0;
+    }
+  }
+
+  function close_def_list() {
+    if ($this->in_def_list) {
+      $this->out .= "</dd></dl>\n";
+    }
+    $this->in_def_list = false;
+  }
+
+  function handle_code_block($line) {
+    if (trim($line) == MTrackWikiParser::STARTBLOCK) {
+      $this->in_code_block++;
+      if ($this->in_code_block == 1) {
+        $this->code_buf = array();
+      } else {
+        $this->code_buf[] = $line;
+      }
+    } elseif (trim($line) == MTrackWikiParser::ENDBLOCK) {
+      $this->in_code_block--;
+      if ($this->in_code_block == 0) {
+        // FIXME process the code here
+        if (preg_match("/^#!(\S+)$/", $this->code_buf[0], $M)
+            && isset(MTrackWiki::$processors[$M[1]])) {
+          $func = MTrackWiki::$processors[$M[1]];
+          array_shift($this->code_buf);
+          $this->out .= call_user_func($func, $M[1], $this->code_buf);
+        } else {
+          $this->out .= "<pre>" .
+            htmlspecialchars(join("\n", $this->code_buf), ENT_COMPAT, 'utf-8') .
+            "</pre>";
+        }
+      } else {
+        $this->code_buf[] = $line;
+      }
+    } else {
+      $this->code_buf[] = $line;
+    }
+  }
+
+  function close_code_blocks() {
+    while ($this->in_code_block) {
+      $this->handle_code_block(MTrackWikiParser::ENDBLOCK);
+    }
+  }
+
+  function _set_tab($depth) {
+    /* Append a new tab if needed and truncate tabs deeper than `depth`
+      given:       -*-----*--*---*--
+      setting:              *
+      results in:  -*-----*-*-------
+    */
+    $tabstops = array();
+    foreach ($this->tabstops as $ts) {
+      if ($ts >= $depth) {
+        break;
+      }
+      $tabstops[] = $ts;
+    }
+    $tabstops[] = $depth;
+    $this->tabstops = $tabstops;
+  }
+
+  function _list_formatter($match, $info, $nmatch) {
+    $ldepth = strlen($info['ldepth'][$nmatch][0]);
+    $listid = $match[$ldepth];
+    $this->in_list_item = true;
+    $class = '';
+    $start = '';
+    if ($listid == '-' || $listid == '*') {
+      $type = 'ul';
+    } else {
+      $type = 'ol';
+      switch ($listid) {
+        case '1': break;
+        case '0': $class = 'arabiczero'; break;
+        case 'i': $class = 'lowerroman'; break;
+        case 'I': $class = 'upperroman'; break;
+        default:
+          if (preg_match("/(\d+)\./", substr($match, $ldepth), $d)) {
+            $start = (int)$d[1];
+          } elseif (ctype_lower($listid)) {
+            $class = 'loweralpha';
+          } elseif (ctype_upper($listid)) {
+            $class = 'upperalpha';
+          }
+      }
+    }
+    $this->_set_list_depth($ldepth, $type, $class, $start);
+  }
+
+  function _definition_formatter($match, $info, $nmatch) {
+    $tmp = $this->in_def_list ? '</dd>' : '<dl class="wikidl">';
+    list($def) = explode('::', $match, 2);
+    $tmp .= sprintf("<dt>%s</dt><dd>",
+      MTrackWiki::format_to_oneliner(trim($def)));
+    $this->in_def_list = true;
+    $this->out .= $tmp;
+  }
+
+  function _indent_formatter($match, $info, $nmatch) {
+    $idepth = strlen($info['idepth'][$nmatch][0]);
+    if (count($this->list_stack)) {
+      list($ltype, $ldepth) = end($this->list_stack);
+      if ($idepth < $ldepth) {
+        foreach ($this->list_stack as $pair) {
+          $ldepth = $pair[1];
+          if ($idepth > $ldepth) {
+            $this->in_list_item = true;
+            $this->_set_list_depth($idepth, null, null, null);
+            return;
+          }
+        }
+      } elseif ($idepth <= $ldepth + ($ltype == 'ol' ? 3 : 2)) {
+        $this->in_list_item = true;
+        return;
+      }
+    }
+    if (!$this->in_def_list) {
+      $this->_set_quote_depth($idepth);
+    }
+  }
+
+  function _citation_formatter($match, $info, $nmatch) {
+    $cdepth = strlen(str_replace(' ', '', $info['cdepth'][$nmatch][0]));
+    $this->_set_quote_depth($cdepth, true);
+  }
+
+
+}
+
+class MTrackWikiOneLinerFormatter extends MTrackWikiHTMLFormatter {
+  function format($text, $escape_newlines = false) {
+    if (!strlen($text)) return;
+    $this->reset();
+    $in_code_block = 0;
+    $num = 0;
+    foreach (preg_split("!\r?\n!", $text) as $line) {
+      if ($num++) $this->out .= ' ';
+      $result = '';
+      if ($this->in_code_block || trim($line) == MTrackWikiParser::STARTBLOCK) {
+        $in_code_block++;
+      } elseif (trim($line) == MTrackWikiParser::ENDBLOCK) {
+        if ($in_code_block) {
+          $in_code_block--;
+          if ($in_code_block == 0) {
+            $result .= " [...]\n";
+          }
+        }
+      } elseif (!$in_code_block) {
+        $result .= "$line\n";
+      }
+
+      $result = $this->_apply_rules(rtrim($result, "\r\n"));
+      $this->out .= $result;
+      $this->close_tag(null);
+    }
+  }
+}
+
+/*
+#error_reporting(E_NOTICE);
+$f = new MTrackWikiHTMLFormatter;
+$f->format(file_get_contents("WikiFormatting"));
+#$f->format("* '''wooot'''\noh '''yeah'''\n\n");
+#$f->format(" < wez@php.net http://foo.com/bar [https://baz.com/flib Flib] [/foo Shoe]\n");
+/*
+$f->format(<<<WIKI
+>> foo
+> bar
+
+all done
+WIKI
+);
+*/
+/*
+echo $f->out, "\n";
+print_r($f->missing);
+echo "\ndone\n";
+*/
diff --git a/schema/0.xml b/schema/0.xml
new file mode 100644 (file)
index 0000000..b238e68
--- /dev/null
@@ -0,0 +1,391 @@
+<schema version='0'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrac will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <key>
+                       <field>repoid</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='autoinc'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='object_tree'>
+               <comment>nested set representation of a tree, see MTrackTree</comment>
+               <field name='objectid' type='text'/>
+               <field name='lseq' type='integer'/>
+               <field name='rseq' type='integer'/>
+               <key><field>objectid</field></key>
+               <key type='multiple' name='idx_obj_tree_lseq'><field>lseq</field></key>
+               <key type='multiple' name='idx_obj_tree_rseq'><field>rseq</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       -- indicates whether the entry applies to this item or its children
+       -- sequence number allows explicit ordering for fine grained
+       -- permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+       -- the object to which this is attached
+       -- sha1 hash of the contents of the attachment
+       -- (used to locate the underlying file)
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <trigger system='sqlite' name='delete_attachment'>
+AFTER DELETE ON attachments
+       BEGIN
+               select mtrack_cleanup_attachments(OLD.hash,
+                       (select count(hash) from attachments));
+       END;
+               </trigger>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+</schema>
diff --git a/schema/1.php b/schema/1.php
new file mode 100644 (file)
index 0000000..677c1ba
--- /dev/null
@@ -0,0 +1,16 @@
+<?php # vim:ts=2:sw=2:et:
+# Convert attachments so that a copy of blob lives in the db
+
+echo "Migrating attachments\n";
+$q = $db->prepare('update attachments set payload = ? where hash = ?');
+
+foreach ($db->query('select hash from attachments')->fetchAll() as $row) {
+  $path = MTrackAttachment::local_path($row['hash']);
+  $fp = fopen($path, 'rb');
+  $q->bindValue(1, $fp, PDO::PARAM_LOB);
+  $q->bindValue(2, $row['hash']);
+  $q->execute();
+  fclose($fp);
+  $fp = null;
+}
+
diff --git a/schema/1.xml b/schema/1.xml
new file mode 100644 (file)
index 0000000..8e91dd4
--- /dev/null
@@ -0,0 +1,402 @@
+<schema version='1'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrac will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <key>
+                       <field>repoid</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='object_tree'>
+               <comment>nested set representation of a tree, see MTrackTree</comment>
+               <field name='objectid' type='text'/>
+               <field name='lseq' type='integer'/>
+               <field name='rseq' type='integer'/>
+               <key><field>objectid</field></key>
+               <key type='multiple' name='idx_obj_tree_lseq'><field>lseq</field></key>
+               <key type='multiple' name='idx_obj_tree_rseq'><field>rseq</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       -- indicates whether the entry applies to this item or its children
+       -- sequence number allows explicit ordering for fine grained
+       -- permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/schema/2.xml b/schema/2.xml
new file mode 100644 (file)
index 0000000..0dd764d
--- /dev/null
@@ -0,0 +1,432 @@
+<schema version='2'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrack will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <field name='serverurl' type='text'>
+                       <comment>
+                               The URL that SCM tools will use to checkout,
+                               clone, push, pull or otherwise interact with
+                               the repo.
+                       </comment>
+               </field>
+               <field name='parent' type='text' nullable='0' default=''>
+                       <comment>
+                       If NULL, this is a global repo.  Otherwise, parent is
+                       a string like 'user:wez' to indicate that it is owned
+                       by 'wez', or 'project:name' to indicate that it is owned
+                       by the 'name' project.
+                       </comment>
+               </field>
+               <field name='clonedfrom' type='integer'
+                       reftable='repos' refcol='repoid'>
+                       <comment>
+                               If this was forked from another repo in the system,
+                               then this field is set to its repoid
+                       </comment>
+               </field>
+               <key>
+                       <field>repoid</field>
+               </key>
+               <key type='unique'>
+                       <field>shortname</field>
+                       <field>parent</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='object_tree'>
+               <comment>nested set representation of a tree, see MTrackTree</comment>
+               <field name='objectid' type='text'/>
+               <field name='lseq' type='integer'/>
+               <field name='rseq' type='integer'/>
+               <key><field>objectid</field></key>
+               <key type='multiple' name='idx_obj_tree_lseq'><field>lseq</field></key>
+               <key type='multiple' name='idx_obj_tree_rseq'><field>rseq</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       -- indicates whether the entry applies to this item or its children
+       -- sequence number allows explicit ordering for fine grained
+       -- permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+               <field name='sshkeys' type='text'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+-- requires postgres 8.2 and higher
+DROP AGGREGATE IF EXISTS mtrack_group_concat(text);
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/schema/3.xml b/schema/3.xml
new file mode 100644 (file)
index 0000000..091a1a5
--- /dev/null
@@ -0,0 +1,422 @@
+<schema version='3'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrack will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <field name='serverurl' type='text'>
+                       <comment>
+                               The URL that SCM tools will use to checkout,
+                               clone, push, pull or otherwise interact with
+                               the repo.
+                       </comment>
+               </field>
+               <field name='parent' type='text' nullable='0' default=''>
+                       <comment>
+                       If NULL, this is a global repo.  Otherwise, parent is
+                       a string like 'user:wez' to indicate that it is owned
+                       by 'wez', or 'project:name' to indicate that it is owned
+                       by the 'name' project.
+                       </comment>
+               </field>
+               <field name='clonedfrom' type='integer'
+                       reftable='repos' refcol='repoid'>
+                       <comment>
+                               If this was forked from another repo in the system,
+                               then this field is set to its repoid
+                       </comment>
+               </field>
+               <key>
+                       <field>repoid</field>
+               </key>
+               <key type='unique'>
+                       <field>shortname</field>
+                       <field>parent</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       indicates whether the entry applies to this item or its children
+       sequence number allows explicit ordering for fine grained
+       permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+               <field name='sshkeys' type='text'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+-- requires postgres 8.2 and higher
+DROP AGGREGATE IF EXISTS mtrack_group_concat(text);
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/schema/4-pre.php b/schema/4-pre.php
new file mode 100644 (file)
index 0000000..1b5adab
--- /dev/null
@@ -0,0 +1,29 @@
+<?php # vim:ts=2:sw=2:et:
+# De-dupe components table
+
+$names = array();
+foreach ($db->query('select compid, name from components')->fetchAll() as $row)
+{
+  $names[$row[1]][] = $row[0];
+}
+
+foreach ($names as $name => $ids) {
+  if (count($ids) == 1) continue;
+  echo "Fixing duplicate component: $name\n";
+  sort($ids);
+  $id = array_shift($ids);
+  $change = join(',', $ids);
+
+  $q = $db->prepare("update ticket_components set compid = ? where compid in ($change)");
+  $q->execute(array($id));
+  $q = $db->prepare("update components_by_project set compid = ? where compid in ($change)");
+  $q->execute(array($id));
+  $comps = array();
+  foreach ($ids as $i) {
+    $comps[] = $db->quote("component:$i");
+  }
+  $comps = join(',', $comps);
+  $db->exec("update changes set object = 'component:$id' where object in ($comps)");
+  $db->exec("delete from components where compid in ($change)");
+}
+
diff --git a/schema/4.xml b/schema/4.xml
new file mode 100644 (file)
index 0000000..0bc9731
--- /dev/null
@@ -0,0 +1,432 @@
+<schema version='4'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrack will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <field name='serverurl' type='text'>
+                       <comment>
+                               The URL that SCM tools will use to checkout,
+                               clone, push, pull or otherwise interact with
+                               the repo.
+                       </comment>
+               </field>
+               <field name='parent' type='text' nullable='0' default=''>
+                       <comment>
+                       If NULL, this is a global repo.  Otherwise, parent is
+                       a string like 'user:wez' to indicate that it is owned
+                       by 'wez', or 'project:name' to indicate that it is owned
+                       by the 'name' project.
+                       </comment>
+               </field>
+               <field name='clonedfrom' type='integer'
+                       reftable='repos' refcol='repoid'>
+                       <comment>
+                               If this was forked from another repo in the system,
+                               then this field is set to its repoid
+                       </comment>
+               </field>
+               <key>
+                       <field>repoid</field>
+               </key>
+               <key type='unique'>
+                       <field>shortname</field>
+                       <field>parent</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+               <key type='unique'><field>summary</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       indicates whether the entry applies to this item or its children
+       sequence number allows explicit ordering for fine grained
+       permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+               <field name='sshkeys' type='text'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+-- requires postgres 8.2 and higher
+DROP AGGREGATE IF EXISTS mtrack_group_concat(text);
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/schema/5.xml b/schema/5.xml
new file mode 100644 (file)
index 0000000..1673cdb
--- /dev/null
@@ -0,0 +1,456 @@
+<schema version='5'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='groups'>
+               <field name='name' type='text' nullable='0'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <key>
+                       <field>name</field>
+                       <field>project</field>
+               </key>
+       </table>
+
+       <table name='group_membership'>
+               <field name='groupname' type='text' nullable='0'
+                       reftable='groups' refcol='name'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <field name='username' type='text' nullable='0'
+                       reftable='userinfo' refcol='userid'/>
+               <key>
+                       <field>groupname</field>
+                       <field>project</field>
+                       <field>username</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrack will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <field name='serverurl' type='text'>
+                       <comment>
+                               The URL that SCM tools will use to checkout,
+                               clone, push, pull or otherwise interact with
+                               the repo.
+                       </comment>
+               </field>
+               <field name='parent' type='text' nullable='0' default=''>
+                       <comment>
+                       If NULL, this is a global repo.  Otherwise, parent is
+                       a string like 'user:wez' to indicate that it is owned
+                       by 'wez', or 'project:name' to indicate that it is owned
+                       by the 'name' project.
+                       </comment>
+               </field>
+               <field name='clonedfrom' type='integer'
+                       reftable='repos' refcol='repoid'>
+                       <comment>
+                               If this was forked from another repo in the system,
+                               then this field is set to its repoid
+                       </comment>
+               </field>
+               <key>
+                       <field>repoid</field>
+               </key>
+               <key type='unique'>
+                       <field>shortname</field>
+                       <field>parent</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+               <key type='unique'><field>summary</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       indicates whether the entry applies to this item or its children
+       sequence number allows explicit ordering for fine grained
+       permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+               <field name='sshkeys' type='text'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+-- requires postgres 8.2 and higher
+DROP AGGREGATE IF EXISTS mtrack_group_concat(text);
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/schema/6.php b/schema/6.php
new file mode 100644 (file)
index 0000000..45caf2e
--- /dev/null
@@ -0,0 +1,9 @@
+<?php # vim:ts=2:sw=2:et:
+
+# Add forking permissions
+$ents = MTrackACL::getACL('Browser', false);
+$ents[] = array('BrowserCreator', 'fork', true);
+$ents[] = array('BrowserForker', 'fork', true);
+$ents[] = array('BrowserForker', 'read', true);
+MTrackACL::setACL('Browser', false, $ents);
+
diff --git a/schema/6.xml b/schema/6.xml
new file mode 100644 (file)
index 0000000..1bcf26f
--- /dev/null
@@ -0,0 +1,456 @@
+<schema version='6'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='groups'>
+               <field name='name' type='text' nullable='0'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <key>
+                       <field>name</field>
+                       <field>project</field>
+               </key>
+       </table>
+
+       <table name='group_membership'>
+               <field name='groupname' type='text' nullable='0'
+                       reftable='groups' refcol='name'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <field name='username' type='text' nullable='0'
+                       reftable='userinfo' refcol='userid'/>
+               <key>
+                       <field>groupname</field>
+                       <field>project</field>
+                       <field>username</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrack will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <field name='serverurl' type='text'>
+                       <comment>
+                               The URL that SCM tools will use to checkout,
+                               clone, push, pull or otherwise interact with
+                               the repo.
+                       </comment>
+               </field>
+               <field name='parent' type='text' nullable='0' default=''>
+                       <comment>
+                       If NULL, this is a global repo.  Otherwise, parent is
+                       a string like 'user:wez' to indicate that it is owned
+                       by 'wez', or 'project:name' to indicate that it is owned
+                       by the 'name' project.
+                       </comment>
+               </field>
+               <field name='clonedfrom' type='integer'
+                       reftable='repos' refcol='repoid'>
+                       <comment>
+                               If this was forked from another repo in the system,
+                               then this field is set to its repoid
+                       </comment>
+               </field>
+               <key>
+                       <field>repoid</field>
+               </key>
+               <key type='unique'>
+                       <field>shortname</field>
+                       <field>parent</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+               <key type='unique'><field>summary</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       indicates whether the entry applies to this item or its children
+       sequence number allows explicit ordering for fine grained
+       permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+               <field name='sshkeys' type='text'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+-- requires postgres 8.2 and higher
+DROP AGGREGATE IF EXISTS mtrack_group_concat(text);
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/schema/7.xml b/schema/7.xml
new file mode 100644 (file)
index 0000000..2749662
--- /dev/null
@@ -0,0 +1,502 @@
+<schema version='7'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='groups'>
+               <field name='name' type='text' nullable='0'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <key>
+                       <field>name</field>
+                       <field>project</field>
+               </key>
+       </table>
+
+       <table name='group_membership'>
+               <field name='groupname' type='text' nullable='0'
+                       reftable='groups' refcol='name'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <field name='username' type='text' nullable='0'
+                       reftable='userinfo' refcol='userid'/>
+               <key>
+                       <field>groupname</field>
+                       <field>project</field>
+                       <field>username</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrack will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <field name='serverurl' type='text'>
+                       <comment>
+                               The URL that SCM tools will use to checkout,
+                               clone, push, pull or otherwise interact with
+                               the repo.
+                       </comment>
+               </field>
+               <field name='parent' type='text' nullable='0' default=''>
+                       <comment>
+                       If NULL, this is a global repo.  Otherwise, parent is
+                       a string like 'user:wez' to indicate that it is owned
+                       by 'wez', or 'project:name' to indicate that it is owned
+                       by the 'name' project.
+                       </comment>
+               </field>
+               <field name='clonedfrom' type='integer'
+                       reftable='repos' refcol='repoid'>
+                       <comment>
+                               If this was forked from another repo in the system,
+                               then this field is set to its repoid
+                       </comment>
+               </field>
+               <key>
+                       <field>repoid</field>
+               </key>
+               <key type='unique'>
+                       <field>shortname</field>
+                       <field>parent</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+               <key type='unique'><field>summary</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       indicates whether the entry applies to this item or its children
+       sequence number allows explicit ordering for fine grained
+       permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+               <field name='sshkeys' type='text'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <table name='watches'>
+               <comment>Records things that are being watched by a given user</comment>
+               <field name='otype' type='text' nullable='0'>
+                       <comment>
+                               The type of object being watched: ticket, repo, user, project,
+                               milestone, wiki
+                       </comment>
+               </field>
+               <field name='oid' type='text' nullable='0'>
+                       <comment>
+                               The id of the object being watched.
+                               If '*', treated as a wildcard for objects of the specified
+                               type.
+                       </comment>
+               </field>
+               <field name='userid' type='text'
+                               reftable='userinfo' refcol='userid' nullable='0'>
+                       <comment>
+                               The person doing the watching
+                       </comment>
+               </field>
+               <field name='event' type='text' nullable='0'>
+                       <comment>
+                               all - interested in all events
+                               tickets - ticket changes
+                               changeset - repo changes
+                       </comment>
+               </field>
+               <key>
+                       <field>otype</field>
+                       <field>oid</field>
+                       <field>userid</field>
+                       <field>event</field>
+                       <field>medium</field>
+               </key>
+               <field name='medium' type='text' nullable='0'>
+                       <comment>
+                               email - receive via email
+                               feed - visible in RSS feed
+                               timeline - show up in timeline by default
+                       </comment>
+               </field>
+               <field name='active' type='integer' nullable='0' default='1'/>
+       </table>
+
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+-- requires postgres 8.2 and higher
+DROP AGGREGATE IF EXISTS mtrack_group_concat(text);
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/schema/8.xml b/schema/8.xml
new file mode 100644 (file)
index 0000000..0030df1
--- /dev/null
@@ -0,0 +1,520 @@
+<schema version='8'>
+       <table name='projects'>
+               <field name='projid' type='autoinc'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'>
+                       <comment>
+                               used to order the project names
+                       </comment>
+               </field>
+               <field name='name' type='text' nullable='0'>
+                       <comment>
+                               readable version of the name
+                       </comment>
+               </field>
+               <field name='shortname' type='varchar(16)' nullable='0'>
+                       <comment>
+                               shorter name
+                       </comment>
+               </field>
+               <field name='notifyemail' type='varchar(320)'>
+                       <comment>
+                               where email notifications are sent
+                       </comment>
+               </field>
+               <key>
+                       <field>projid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='groups'>
+               <field name='name' type='text' nullable='0'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <key>
+                       <field>name</field>
+                       <field>project</field>
+               </key>
+       </table>
+
+       <table name='group_membership'>
+               <field name='groupname' type='text' nullable='0'
+                       reftable='groups' refcol='name'/>
+               <field name='project' type='integer' nullable='0'
+                       reftable='projects' refcol='projid'/>
+               <field name='username' type='text' nullable='0'
+                       reftable='userinfo' refcol='userid'/>
+               <key>
+                       <field>groupname</field>
+                       <field>project</field>
+                       <field>username</field>
+               </key>
+       </table>
+
+       <table name='repos'>
+               <field name='repoid' type='autoinc'/>
+               <field name='shortname' type='varchar(16)' nullable='0'/>
+               <field name='scmtype' type='varchar(32)' nullable='0'/>
+               <field name='repopath' type='text' nullable='0'/>
+               <field name='browserurl' type='text'>
+                       <comment>
+                       if defined, mtrack will use this as the base for links
+                       to changesets and repo browsing, otherwise it will
+                       handle it locally
+                       </comment>
+               </field>
+               <field name='browsertype' type='text'/>
+               <field name='description' type='text'/>
+               <field name='serverurl' type='text'>
+                       <comment>
+                               The URL that SCM tools will use to checkout,
+                               clone, push, pull or otherwise interact with
+                               the repo.
+                       </comment>
+               </field>
+               <field name='parent' type='text' nullable='0' default=''>
+                       <comment>
+                       If NULL, this is a global repo.  Otherwise, parent is
+                       a string like 'user:wez' to indicate that it is owned
+                       by 'wez', or 'project:name' to indicate that it is owned
+                       by the 'name' project.
+                       </comment>
+               </field>
+               <field name='clonedfrom' type='integer'
+                       reftable='repos' refcol='repoid'>
+                       <comment>
+                               If this was forked from another repo in the system,
+                               then this field is set to its repoid
+                       </comment>
+               </field>
+               <key>
+                       <field>repoid</field>
+               </key>
+               <key type='unique'>
+                       <field>shortname</field>
+                       <field>parent</field>
+               </key>
+       </table>
+
+       <table name='project_repo_link'>
+               <comment>
+Links a location within a repo to its "parent" project.
+This allows multiple projects to exist within a repository
+and also allows pre/post commit rules to determine whether
+the location is a personal branch or scratch space, versus
+a formal project branch.
+               </comment>
+               <field name='linkid' type='autoinc'/>
+               <field name='projid' type='integer' reftable='projects' refcol='projid'
+                       nullable='0'/>
+               <field name='repoid' type='integer' reftable='repos' refcol='repoid'
+                       nullable='0'/>
+               <field name='repopathregex' type='text'/>
+               <field name='is_scratch_space' type='integer' nullable='0' default='0'>
+                       <comment>
+                       May replace this with a reference to a workflow or other kind
+                       of ruleset to affect pre/post commit
+                       </comment>
+               </field>
+               <key>
+                       <field>linkid</field>
+               </key>
+       </table>
+
+       <table name='components'>
+               <field name='compid' type='autoinc'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='name' type='text'/>
+               <key>
+                       <field>compid</field>
+               </key>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+       </table>
+
+       <table name='components_by_project'>
+               <field name='projid' type='integer'/>
+               <field name='compid' type='integer'
+                       reftable='components' refcol='compid' nullable='0'/>
+               <key><field>projid</field><field>compid</field></key>
+       </table>
+
+       <table name='priorities'>
+               <field name='priorityname' type='varchar(32)' nullable='0'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='value' type='integer' nullable='0' default='5'/>
+               <key><field>priorityname</field></key>
+       </table>
+
+       <table name='severities'>
+               <field name='sevname' type='varchar(32)' nullable='0'/>
+               <key><field>sevname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+
+       <table name='resolutions'>
+               <field name='resname' type='varchar(32)' nullable='0'/>
+               <key><field>resname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='classifications'>
+               <field name='classname' type='varchar(32)' nullable='0'/>
+               <key><field>classname</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='ticketstates'>
+               <field name='statename' type='varchar(32)' nullable='0'/>
+               <key><field>statename</field></key>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='ordinal' type='integer' nullable='0' default='5'/>
+       </table>
+       <table name='keywords'>
+               <field name='kid' type='autoinc'/>
+               <key><field>kid</field></key>
+               <key type='unique'><field>keyword</field></key>
+               <field name='keyword' type='text' nullable='0'/>
+       </table>
+       <table name='changes'>
+               <field name='cid' type='autoinc'/>
+               <field name='who' type='text'/>
+               <field name='object' type='text'>
+                       <comment>
+                       usually tablename:id
+                       where id is a comma separated list of the primary key fields
+                       of the object that was edited
+                       </comment>
+               </field>
+               <field name='changedate' type='timestamp' nullable='0'
+                       default='CURRENT_TIMESTAMP'/>
+               <field name='reason' type='text'>
+                       <comment>
+                               commit/changelog message
+                       </comment>
+               </field>
+               <key><field>cid</field></key>
+               <key type='multiple' name='idx_changes_object'><field>object</field></key>
+               <key type='multiple' name='idx_changes_date'><field>changedate</field></key>
+       </table>
+
+       <table name='change_audit'>
+               <field name='cid' type='integer' nullable='0'
+                       reftable='changes' refcol='cid'/>
+               <field name='fieldname' type='text'/>
+               <field name='action' type='varchar(16)'>
+                       <comment>
+       set, changed, deleted, added, removed.
+       set: filled in from a blank value
+       changed: changed existing value. value field has old value.
+       deleted: set value to blank, value field has old value
+       added: used for associated values (like keywords); the value field
+              lists out the primary keys of the added items, comma separated.
+       removed: used for associated values (like keywords); the value field
+                lists out the primary keys of the removed items, comma separated
+                        </comment>
+               </field>
+               <field name='action' type='varchar(16)'/>
+               <field name='oldvalue' type='text'/>
+               <field name='value' type='text'/>
+       </table>
+
+       <table name='milestones'>
+               <field name='mid' type='autoinc'/>
+               <key><field>mid</field></key>
+               <field name='name' type='text'/>
+               <key type='unique'>
+                       <field>name</field>
+               </key>
+               <field name='description' type='text'/>
+               <field name='startdate' type='timestamp'/>
+               <field name='duedate' type='timestamp'/>
+               <field name='completed' type='timestamp'/>
+               <field name='deleted' type='integer' nullable='0' default='0'/>
+               <field name='created' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer' nullable='0'
+                               reftable='changes' refcol='cid'/>
+               <field name='pmid' type='integer' reftable='milestones' refcol='mid'>
+                       <comment>
+                               parent milestone (for sprint support)
+                       </comment>
+               </field>
+       </table>
+
+       <table name='tickets'>
+               <field name='tid' type='char(32)' nullable='0'>
+                       <comment>unique identifier (short form UUID)</comment>
+               </field>
+               <field name='nsident' type='text' nullable='0'>
+                       <comment>
+       identifier assigned within a particular namespace
+       eg: when a ticket is accepted as a bug, will be assigned
+       a bug number for that project
+                       </comment>
+               </field>
+       
+               <field name='summary' type='text' nullable='0'>
+                       <comment>
+       -- one line summary
+       -- problem description in detail
+                       </comment>
+               </field>
+               <field name='description' type='text'/>
+
+               <field name='changelog' type='text'>
+                       <comment>
+       -- end-user (or customer) facing summary, suitable for use in
+       -- a release notes or ChangeLog format
+                       </comment>
+               </field>
+
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+
+               <field name='owner' type='text'/>
+               <field name='priority' type='text'/>
+               <field name='severity' type='text'/>
+               <field name='classification' type='text'/>
+               <field name='resolution' type='text'/>
+               <field name='cc' type='text'/>
+
+               <field name='status' type='text' nullable='0'/>
+               <field name='estimated' type='real'/>
+               <field name='spent' type='real'/>
+
+               <key><field>tid</field></key>
+               <key type='unique'><field>nsident</field></key>
+       </table>
+
+       <table name='ticket_components'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='compid' type='integer' nullable='0'
+                       reftable='components' refcol='cid'/>
+       </table>
+
+       <table name='ticket_milestones'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='mid' type='integer' nullable='0'
+                       reftable='milestones' refcol='mid'/>
+       </table>
+
+       <table name='ticket_keywords'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='kid' type='integer' nullable='0'
+                       reftable='keywords' refcol='kid'/>
+       </table>
+
+       <table name='ticket_changeset_hashes'>
+               <field name='tid' type='char(32)' nullable='0'
+                       reftable='tickets' refcol='tid'/>
+               <field name='hash' type='text'>
+                       <comment>
+                               For distributed version control, we may push the same
+                               changes into multiple repos that we maintain in the same
+                               mtrack instance.  We don't want to count any spent time
+                               more than once, so we allow storing a hash with each
+                               ticket.
+                       </comment>
+               </field>
+               <key>
+                       <field>tid</field>
+                       <field>hash</field>
+               </key>
+       </table>
+               
+       <table name='reports'>
+               <field name='rid' type='autoinc'/>
+               <field name='summary' type='text' nullable='0'/>
+               <field name='description' type='text' nullable='0'/>
+               <field name='query' type='text' nullable='0'/>
+               <field name='changed' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <key><field>rid</field></key>
+               <key type='unique'><field>summary</field></key>
+       </table>
+
+       <table name='effort'>
+               <field name='eid' type='autoinc'/>
+               <key><field>eid</field></key>
+               <field name='tid' type='char(32)' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='expended' type='real'/>
+               <field name='remaining' type='real'>
+                       <comment>revised estimate</comment>
+               </field>
+               <key type='multiple' name='idx_effort_ticket'><field>tid</field></key>
+       </table>
+
+       <table name='acl'>
+               <comment>access control list</comment>
+               <field name='objectid' type='text'/>
+               <field name='cascade' type='integer' nullable='0'>
+                       <comment>
+       indicates whether the entry applies to this item or its children
+       sequence number allows explicit ordering for fine grained
+       permissions (exclude all members of a group, except a particular user)
+                       </comment>
+               </field>
+               <field name='seq' type='integer' nullable='0'/>
+               <field name='role' type='text' nullable='0'>
+                       <comment>user or group name</comment>
+               </field>
+               <field name='action' type='text' nullable='0'>
+                       <comment>
+               -- activity or action name ("read", "write")
+               -- whether access is allowed
+                       </comment>
+               </field>
+               <field name='allow' type='integer' nullable='0'/>
+               <key>
+                       <field>objectid</field>
+                       <field>seq</field>
+                       <field>cascade</field>
+               </key>
+               <key type='multiple' name='idx_acl_role'>
+                       <field>role</field>
+               </key>
+       </table>
+
+       <table name='userinfo'>
+               <field name='userid' type='text' nullable='0'>
+                       <comment>canonical user id</comment>
+               </field>
+               <key><field>userid</field></key>
+               <field name='fullname' type='text'/>
+               <field name='email' type='text'/>
+               <field name='timezone' type='text'/>
+               <field name='active' type='integer' nullable='0' default='1'/>
+               <field name='sshkeys' type='text'/>
+       </table>
+
+       <table name='useraliases'>
+               <field name='alias' type='text' nullable='0'/>
+               <key><field>alias</field></key>
+               <field name='userid' type='text' reftable='userinfo' refcol='userid'/>
+       </table>
+
+       <table name='attachments'>
+               <field name='object' type='text' nullable='0'>
+                       <comment>
+                               the object to which this is attached
+                               sha1 hash of the contents of the attachment
+                       </comment>
+               </field>
+               <field name='hash' type='text' nullable='0'/>
+               <field name='filename' type='text' nullable='0'/>
+               <field name='size' type='integer' nullable='0'/>
+               <field name='cid' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='payload' type='blob'/>
+       </table>
+
+       <table name='last_notification'>
+               <comment>last time that we procesed change notifications</comment>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+       <table name='search_engine_state'>
+               <field name='last_run' type='timestamp' nullable='0'/>
+               <key><field>last_run</field></key>
+       </table>
+
+       <table name='snippets'>
+               <field name='snid' type='text' nullable='0'>
+                       <comment>snippet id</comment>
+               </field>
+               <field name='created' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='updated' type='integer'
+                       nullable='0' reftable='changes' refcol='cid'/>
+               <field name='description' type='text' nullable='0'>
+                       <comment>summary/blurb in wiki markup</comment>
+               </field>
+               <field name='lang' type='text' nullable='0'>
+                       <comment>what language?</comment>
+               </field>
+               <field name='snippet' type='text' nullable='0'>
+                       <comment>and the snippet itself</comment>
+               </field>
+               <key><field>snid</field></key>
+       </table>
+
+       <table name='watches'>
+               <comment>Records things that are being watched by a given user</comment>
+               <field name='otype' type='text' nullable='0'>
+                       <comment>
+                               The type of object being watched: ticket, repo, user, project,
+                               milestone, wiki
+                       </comment>
+               </field>
+               <field name='oid' type='text' nullable='0'>
+                       <comment>
+                               The id of the object being watched.
+                               If '*', treated as a wildcard for objects of the specified
+                               type.
+                       </comment>
+               </field>
+               <field name='userid' type='text'
+                               reftable='userinfo' refcol='userid' nullable='0'>
+                       <comment>
+                               The person doing the watching
+                       </comment>
+               </field>
+               <field name='event' type='text' nullable='0'>
+                       <comment>
+                               all - interested in all events
+                               tickets - ticket changes
+                               changeset - repo changes
+                       </comment>
+               </field>
+               <key>
+                       <field>otype</field>
+                       <field>oid</field>
+                       <field>userid</field>
+                       <field>event</field>
+                       <field>medium</field>
+               </key>
+               <field name='medium' type='text' nullable='0'>
+                       <comment>
+                               email - receive via email
+                               feed - visible in RSS feed
+                               timeline - show up in timeline by default
+                       </comment>
+               </field>
+               <field name='active' type='integer' nullable='0' default='1'/>
+       </table>
+
+
+       <post driver="pgsql">
+CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text)
+       RETURNS text as $$
+SELECT CASE
+       WHEN $2 IS NULL THEN $1
+       WHEN $1 IS NULL THEN $2
+ELSE
+       $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2
+END
+$$ IMMUTABLE LANGUAGE SQL;
+
+-- requires postgres 8.2 and higher
+DROP AGGREGATE IF EXISTS mtrack_group_concat(text);
+
+CREATE AGGREGATE mtrack_group_concat(
+       BASETYPE = text,
+       SFUNC = _mtrack_group_concat,
+       STYPE = text
+);
+       </post>
+
+</schema>
diff --git a/web/.htaccess b/web/.htaccess
new file mode 100644 (file)
index 0000000..a053028
--- /dev/null
@@ -0,0 +1 @@
+php_value magic_quotes_gpc off
diff --git a/web/admin/auth.php b/web/admin/auth.php
new file mode 100644 (file)
index 0000000..e3189ea
--- /dev/null
@@ -0,0 +1,294 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+MTrackACL::requireAnyRights('User', 'modify');
+$plugins = MTrackConfig::getSection('plugins');
+
+function get_openid_admins()
+{
+  $admins = array();
+  $regadmins = array();
+  foreach (MTrackConfig::getSection('user_classes') as $id => $role) {
+    if ($role == 'admin') {
+      if (preg_match('@^https?://@', $id)) {
+        $admins[] = $id;
+      } else {
+        $regadmins[$id] = $id;
+      }
+    }
+  }
+  if (count($regadmins)) {
+    /* look at aliases to see if there are any that look like OpenIDs */
+    foreach (MTrackDB::q('select alias, userid from useraliases')->fetchAll()
+        as $row) {
+      if (!preg_match('@^https?://@', $row[0])) {
+        continue;
+      }
+      if (isset($regadmins[$row[1]])) {
+        $admins[] = $row[0];
+      }
+    }
+  }
+  return $admins;
+}
+
+function get_admins()
+{
+  $admins = array();
+  foreach (MTrackConfig::getSection('user_classes') as $id => $role) {
+    if ($role == 'admin' && !preg_match('@^https?://@', $id)) {
+      $admins[] = $id;
+    }
+  }
+  return $admins;
+}
+
+$message = null;
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  if (isset($_POST['setuppublic'])) {
+    $admins = get_openid_admins();
+    $add_admin = isset($_POST['adminopenid']) ?
+                    trim($_POST['adminopenid']) : '';
+    $localid = isset($_POST['adminuserid']) ?
+                    trim($_POST['adminuserid']) : '';
+    if (count($admins) == 0 && (!strlen($add_admin) || !strlen($localid))) {
+      $message = "You MUST add an OpenID for the administrator";
+    } else {
+      if (strlen($localid)) {
+        MTrackConfig::set('user_classes', $localid, 'admin');
+      }
+      $new = true;
+      foreach (MTrackDB::q('select userid from userinfo where userid = ?',
+          $localid)->fetchAll() as $row) {
+        $new = false;
+        break;
+      }
+      if ($new) {
+        MTrackDB::q('insert into userinfo (userid, active) values (?, 1)', $localid);
+      }
+      $new = true;
+      foreach (MTrackDB::q('select userid from useraliases where alias = ?', $add_admin)->fetchAll() as $row) {
+        if ($row[0] != $localid) {
+          throw new Exception("$add_admin is already associated with $row[0]");
+        }
+        $new = false;
+      }
+      if ($new) {
+        MTrackDB::q('insert into useraliases (userid, alias) values (?,?)',
+          $localid, $add_admin);
+      }
+
+      MTrackConfig::set('plugins', 'MTrackAuth_OpenID', '');
+      if (isset($plugins['MTrackAuth_HTTP'])) {
+        MTrackConfig::remove('plugins', 'MTrackAuth_HTTP');
+        // Reset anonymous for public access
+        MTrackConfig::remove('user_class_roles', 'anonymous');
+      }
+
+      MTrackConfig::save();
+      header("Location: {$ABSWEB}admin/auth.php");
+      exit;
+    }
+  } elseif (isset($_POST['setupprivate'])) {
+    $admins = get_admins();
+    $add_admin = isset($_POST['adminuser']) ?
+                    trim($_POST['adminuser']) : '';
+    if (count($admins) == 0 && !strlen($add_admin)) {
+      $message = "You MUST add a user with admin rights";
+    } else {
+      $vardir = MTrackConfig::get('core', 'vardir');
+      $pfile = "$vardir/http.user";
+
+      if (strlen($add_admin)) {
+        if (!isset($_SERVER['REMOTE_USER'])) {
+          // validate the password
+          if ($_POST['adminpass1'] != $_POST['adminpass2']) {
+            $message = "Passwords don't match";
+          } else {
+            $http_auth = new MTrackAuth_HTTP(null, "digest:$pfile");
+            $http_auth->setUserPassword($add_admin, $_POST['adminpass1']);
+          }
+        }
+        MTrackConfig::set('user_classes', $add_admin, 'admin');
+      }
+      if ($message == null) {
+        if (!isset($plugins['MTrackAuth_HTTP'])) {
+          MTrackConfig::set('plugins', 'MTrackAuth_HTTP',
+              "$vardir/http.group, digest:$pfile");
+        }
+        if (isset($plugins['MTrackAuth_OpenID'])) {
+          MTrackConfig::remove('plugins', 'MTrackAuth_OpenID');
+          // Set up the roles for private access
+          // Use default authenticated permissions
+          MTrackConfig::remove('user_class_roles', 'authenticated');
+          // Make anonymous have no rights
+          MTrackConfig::set('user_class_roles', 'anonymous', '');
+        }
+        MTrackConfig::save();
+        header("Location: {$ABSWEB}admin/auth.php");
+        exit;
+      }
+    }
+  }
+}
+
+mtrack_head("Administration - Authentication");
+
+$plugins = MTrackConfig::getSection('plugins');
+$http_configd = isset($plugins['MTrackAuth_HTTP']) ?  " (Active)" : '';
+$openid_configd = isset($plugins['MTrackAuth_OpenID']) ?  " (Active)" : '';
+
+
+?>
+<h1>Authentication</h1>
+<?php
+if ($message) {
+  $message = htmlentities($message, ENT_QUOTES, 'utf-8');
+  echo <<<HTML
+<div class='ui-state-error ui-corner-all'>
+    <span class='ui-icon ui-icon-alert'></span>
+    $message
+</div>
+HTML;
+}
+
+
+?>
+<p>
+Select one of the following, depending
+on which one best matches your intended mtrack deployment:
+</p>
+
+<form method='post'>
+<div id="authaccordion">
+<h2><a href='#'>Private (HTTP authentication)<?php echo $http_configd ?></a></h2>
+<div>
+<p>
+  I want to strictly control who has access to mtrack, and prevent
+  anonymous users from having any rights.
+</p>
+<?php
+if (isset($_SERVER['REMOTE_USER'])) {
+?>
+<p>
+  It looks like your web server is configured to use HTTP authentication
+  (you're authenticated as <?php
+    echo htmlentities($_SERVER['REMOTE_USER'], ENT_QUOTES, 'utf-8') ?>)
+  mtrack will defer to your web server configuration for authentication.
+  Contact your system administrator to add or remove users, or to change
+  their passwords.  You may still use the mtrack user management screens
+  to change rights assignments for the users.
+</p>
+<?php
+} else {
+?>
+<p>
+  mtrack will use HTTP authentication and store the password and group
+  files in the <em>vardir</em>.
+</p>
+<?php
+}
+echo "<h3>Administrators</h3>";
+$admins = get_admins();
+if (count($admins)) {
+  echo "<p>The following users are configured with admin rights:</p>";
+  echo "<p>";
+  foreach ($admins as $id) {
+    echo mtrack_username($id) . " ";
+  }
+  echo "</p>";
+} else {
+  echo <<<HTML
+<p>You <em>MUST</em> add at least one user as an administrator,
+otherwise no one will be able to administer the system without editing
+the config.ini file.
+</p>
+HTML;
+
+  echo <<<HTML
+<table>
+<tr>
+<td><b>Add Admin User</b>:</td>
+<td><input type="text" name="adminuser"></td>
+</tr>
+HTML;
+
+  if (!isset($_SERVER['REMOTE_USER'])) {
+    echo <<<HTML
+<tr>
+  <td><b>Set Password</b>:</td>
+  <td><input type="password" name="adminpass1"></td>
+</tr>
+<tr>
+  <td><b>Confirm Password</b>:</td>
+  <td><input type="password" name="adminpass2"></td>
+</tr>
+</table>
+HTML;
+  } else {
+    echo <<<HTML
+</table>
+<p>
+<em>You can't set the password here, because mtrack has no way to automatically
+find out how to do that.  Contact your system administrator to ensure that
+you have a username and password configured for mtrack</em></p>
+HTML;
+  }
+}
+?>
+  <input type='submit' name='setupprivate'
+    value='Configure Private Authentication'>
+
+</div>
+<h2><a href='#'>Public (OpenID)<?php echo $openid_configd ?></a></h2>
+<div>
+<p>
+  I want to allow the public access to mtrack, but only allow people that
+  I trust to make certain kinds of changes.
+</p>
+<p>
+  mtrack will use OpenID to manage authentication.
+</p>
+<h3>Administrators</h3>
+<?php
+$admins = get_openid_admins();
+if (count($admins)) {
+  echo "<p>The following OpenID users are configured with admin rights:</p>";
+  echo "<p>";
+  foreach ($admins as $id) {
+    echo mtrack_username($id) . " ($id) ";
+  }
+  echo "</p>";
+} else {
+  echo <<<HTML
+<p>You <em>MUST</em> add at least one OpenID as an administrator,
+otherwise no one will be able to administer the system without editing
+the config.ini file.
+</p>
+HTML;
+}
+?>
+<b>Add Admin OpenID</b>: <input type="text" name="adminopenid"><br>
+<b>Local Username</b>: <input type="text" name="adminuserid"><br>
+  <input type='submit' name='setuppublic'
+    value='Configure Public Authentication'>
+</div>
+</div>
+</form>
+<script>
+$(document).ready(function () {
+  $('#authaccordion').accordion({
+    active: <?php
+  if (isset($plugins['MTrackAuth_OpenID'])) {
+    echo "1";
+  } else {
+    echo "0";
+  }
+?>});
+});
+</script>
+<?php
+mtrack_foot();
+
diff --git a/web/admin/component.php b/web/admin/component.php
new file mode 100644 (file)
index 0000000..b7716e0
--- /dev/null
@@ -0,0 +1,104 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+MTrackACL::requireAnyRights('Components', 'modify');
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  if (isset($_POST['newcomponent']) && strlen($_POST['newcomponent'])) {
+    $CS = MTrackChangeset::begin("component:X",
+        "Added Component $_POST[newcomponent]");
+    $comp = new MTrackComponent;
+    $comp->name = $_POST['newcomponent'];
+    $comp->setProjects($_POST['newcomponentprojects']);
+    $comp->save($CS);
+    $CS->setObject("component:$comp->compid");
+    $CS->commit();
+  }
+  foreach ($_POST as $name => $value) {
+    if (preg_match("/^comp:(\d+):name$/", $name, $M)) {
+      $compid = (int)$M[1];
+      $C = MTrackComponent::loadById($compid);
+      $changed = false;
+
+      if ($C->name != $_POST["comp:$compid:name"]) {
+        $C->name = $_POST["comp:$compid:name"];
+        $changed = true;
+      }
+      if (isset($_POST["comp:$compid:deleted"]) &&
+          $_POST["comp:$compid:deleted"] == "on") {
+        $deleted = '1';
+      } else {
+        $deleted = '';
+      }
+      if ($C->deleted != $deleted) {
+        $C->deleted = $deleted;
+        $changed = true;
+      }
+      $plist = $_POST["comp:$compid:projects"];
+      if (is_array($plist)) {
+        asort($plist);
+      }
+      if ($plist != $C->getProjects()) {
+        $C->setProjects($plist);
+        $changed = true;
+      }
+      if ($changed) {
+        $CS = MTrackChangeset::begin("component:$compid",
+            "Edit Component $C->name");
+
+        $C->save($CS);
+        $CS->commit();
+      }
+    }
+  }
+  header("Location: ${ABSWEB}admin/");
+  exit;
+}
+
+mtrack_head("Administration - Components");
+
+echo "<form method='post'>";
+echo "<br><b>Components</b><br>\n";
+echo "<table><tr><th>Name</th><th>Projects</th><th>Deleted</th></tr>\n";
+
+$projects = array();
+foreach (MTrackDB::q('select projid, name, shortname from projects
+    order by name')->fetchAll() as $row) {
+  if ($row[1] != $row[2]) {
+    $projects[$row[0]] = $row[1] . " ($row[2])";
+  } else {
+    $projects[$row[0]] = $row[1];
+  }
+}
+
+$p_by_c = array();
+foreach (MTrackDB::q('select compid, projid from components_by_project')
+    ->fetchAll() as $row) {
+  $p_by_c[$row[0]][$row[1]] = $row[1];
+}
+
+foreach (MTrackDB::q('select compid, name, deleted from components order by name')->fetchAll() as $row) {
+  $compid = (int)$row[0];
+  $name = htmlentities($row[1], ENT_QUOTES, 'utf-8');
+  $del = $row[2] ? ' checked="checked" ' : '';
+  echo "<tr>" .
+    "<td><input type='text' name='comp:$compid:name' value='$name'></td>" .
+    "<td>" . mtrack_multi_select_box("comp:$compid:projects",
+      "(select to add)", $projects, $p_by_c[$compid]) .
+      "</td>" .
+      "<td><input type='checkbox' name='comp:$compid:deleted' $del></td>" .
+      "</tr>\n";
+}
+
+echo "<tr><td><input type='text' name='newcomponent' value=''></td>" .
+  "<td>" . mtrack_multi_select_box('newcomponentprojects',
+    "(select to add)", $projects) .
+    "</td><td>Add a new Component</td></tr>\n";
+
+echo "</table>\n";
+
+echo "<button>Save Changes</button></form>";
+
+mtrack_foot();
+
diff --git a/web/admin/customfield.php b/web/admin/customfield.php
new file mode 100644 (file)
index 0000000..6c5db50
--- /dev/null
@@ -0,0 +1,199 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+MTrackACL::requireAnyRights('Enumerations', 'modify');
+
+$C = MTrackTicket_CustomFields::getInstance();
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+
+  $name = $_POST['name'];
+  $type = $_POST['type'];
+  $group = $_POST['group'];
+  $label = $_POST['label'];
+  $options = $_POST['options'];
+  $default = $_POST['default'];
+  $order = (int)$_POST['order'];
+
+  if (!isset($C->field_types[$type])) {
+    throw new Exception("invalid type $type");
+  }
+
+  $name = MTrackTicket_CustomField::canonName($name);
+  if (!preg_match("/^x_[a-z_]+$/", $name)) {
+    throw new Exception("invalid field name $name");
+  }
+
+  $field = $C->fieldByName($name, true);
+
+  if (isset($_POST['delete'])) {
+    $C->deleteField($field);
+  } else {
+    $field->group = $group;
+    $field->label = $label;
+    $field->type = $type;
+    $field->order = $order;
+    $field->options = $options;
+    $field->default = $default;
+  }
+
+  $C->save();
+  MTrackConfig::save();
+
+  header("Location: ${ABSWEB}admin/customfield.php");
+  exit;
+}
+
+mtrack_head("Administration - Custom Fields");
+echo "<h1>Custom Fields</h1>";
+
+
+$field = null;
+if (isset($_GET['add'])) {
+  $field = new MTrackTicket_CustomField;
+  $field->type = 'text';
+  $field->name = 'x_fieldname';
+  $field->label = 'The Label';
+  $field->group = 'Custom Fields';
+} else if (isset($_GET['field'])) {
+  $field = $C->fieldByName($_GET['field']);
+  if ($field === null) {
+    throw new Exception("No such field " . $_GET['field']);
+  }
+}
+
+if ($field) {
+  $type = mtrack_select_box('type', $C->field_types, $field->type);
+  $name = htmlentities($field->name, ENT_QUOTES, 'utf-8');
+  $label = htmlentities($field->label, ENT_QUOTES, 'utf-8');
+  $group = htmlentities($field->group, ENT_QUOTES, 'utf-8');
+  $options = htmlentities($field->options, ENT_QUOTES, 'utf-8');
+  $default = htmlentities($field->default, ENT_QUOTES, 'utf-8');
+  $order = $field->order;
+?>
+<form method='post' id='editfield'>
+  <fieldset>
+  <legend>Edit Custom Field</legend>
+  <table>
+    <tr>
+      <td><label for='name'>Name</label></td>
+      <td><input type='text' name='name' value='<?php echo $name ?>'><br>
+        <em>The field name to use in the database.  Must have a prefix
+          of 'x_' and must only contain characters a-z or underscore.
+          You cannot rename a field; once it is created, it stays
+          in the database.  You can use the label field below if you
+          want to change the presentation.</em></td>
+    </tr>
+    <tr>
+      <td><label for='type'>Type</label></td>
+      <td><?php echo $type ?></td>
+    </tr>
+    <tr>
+      <td><label for='label'>Label</label></td>
+      <td><input type='text' name='label' value='<?php echo $label ?>'><br>
+        <em>The label to display on the ticket screen</em></td>
+    </tr>
+    <tr>
+      <td><label for='group'>Group</label></td>
+      <td><input type='text' name='group' value='<?php echo $group ?>'><br>
+        <em>Fields with the same group are grouped together on the ticket
+          editing screen</em></td>
+    </tr>
+    <tr>
+      <td><label for='default'>Default</label></td>
+      <td><input type='text' name='default' value='<?php echo $default ?>'><br>
+        <em>Enter the default value for this field</em></td>
+    </tr>
+
+    <tr>
+      <td><label for='options'>Options</label></td>
+      <td><input type='text' name='options' value='<?php echo $options ?>'><br>
+        <em>For Select and Multi-Select types, enter a list of possible
+          choices here, separated by a pipe character |</em></td>
+    </tr>
+    <tr>
+      <td><label for='order'>Sort Order</label></td>
+      <td>
+        <input type='text' name='order' value='<?php echo $order ?>'><br>
+        <em>Lower means show first.  If two or more fields have same 'order',
+          then they are ordered by name</em>
+      </td>
+    </tr>
+  </table>
+  <button type='submit'>Save</button>
+  <button type='submit' name='cancel'>Cancel</button>
+  <button type='submit' name='delete' id='delete-field'>Delete</button>
+  </fieldset>
+</form>
+<div id="confirmDeleteDialog" style="display:none"
+    title="Are you sure?">
+  <p>
+    Deleting the field will hide it from the user interface; it will
+    not remove it from the database.
+  </p>
+  <p>
+    If you add the field back later on, the data previously entered
+    will be visible again.
+  </p>
+</div>
+
+<script>
+$(document).ready(function () {
+var delete_button = $('#delete-field');
+var edit_form = $('#editfield');
+
+$('#confirmDeleteDialog').dialog({
+  autoOpen: false,
+  bgiframe: true,
+  resizable: false,
+  modal: true,
+  buttons: {
+    'Delete': function() {
+      $(this).dialog('close');
+      delete_ok = true;
+      edit_form.append('<input type="hidden" name="delete" value="delete">');
+      delete_button.remove();
+      edit_form.submit();
+    },
+    'Keep': function() {
+      $(this).dialog('close');
+    }
+  }
+});
+$('#delete-field').click(
+  function() {
+    $('#confirmDeleteDialog').dialog('open');
+    return false;
+  }
+);
+});
+</script>
+<?php
+} else {
+
+$grouped = $C->getGroupedFields();
+
+foreach ($grouped as $groupname => $group) {
+  $groupname = htmlentities($groupname, ENT_QUOTES, 'utf-8');
+  echo "<b>Group: $groupname</b><br>\n<table>\n";
+  foreach ($group as $field) {
+    $type = $field->type;
+    $label = htmlentities($field->label, ENT_QUOTES, 'utf-8');
+    $name = $field->name;
+    $name = "<a href=\"{$ABSWEB}admin/customfield.php?field=$name\">$name</a>";
+    echo "<tr><td>$name</td><td>$type</td><td>$label</td></tr>\n";
+  }
+  echo "</table>\n";
+}
+
+?>
+<form method='get'>
+  <input type='hidden' name='add' value='1'>
+  <button type='submit'>Add New Field</button>
+</form>
+<?php
+}
+
+mtrack_foot();
+
diff --git a/web/admin/deleterepo.php b/web/admin/deleterepo.php
new file mode 100644 (file)
index 0000000..dcb3eda
--- /dev/null
@@ -0,0 +1,18 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  $rid = $_POST['repoid'];
+
+  MTrackACL::requireAllRights("repo:$rid", 'delete');
+
+  $S = MTrackRepo::loadById($rid);
+  $CS = MTrackChangeset::begin("repo:$rid", "Delete repo $S->shortname");
+  $S->deleteRepo($CS);
+  $CS->commit();
+}
+
+header("Location: ${ABSWEB}browse.php");
+exit;
+
diff --git a/web/admin/enum.php b/web/admin/enum.php
new file mode 100644 (file)
index 0000000..77580b4
--- /dev/null
@@ -0,0 +1,88 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+MTrackACL::requireAnyRights('Enumerations', 'modify');
+
+$ename = mtrack_get_pathinfo();
+$enums = array('Priority', 'TicketState', 'Severity', 'Resolution', 'Classification');
+
+if (!in_array($ename, $enums)) {
+  throw new Exception("Invalid enum type");
+}
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  $cls = 'MTrack' . $ename;
+  if (isset($_POST["$ename:name:"]) && strlen($_POST["$ename:name:"])) {
+    $obj = new $cls;
+    $obj->name = $_POST["$ename:name:"];
+    $obj->value = $_POST["$ename:value:"];
+    $CS = MTrackChangeset::begin("enum:$obj->tablename:$obj->name",
+        "added $ename $obj->name");
+    $obj->save($CS);
+    $CS->commit();
+  }
+
+  foreach ($_POST as $name => $value) {
+    if (preg_match("/^$ename:value:(.+)$/", $name, $M)) {
+      $n = $M[1];
+      $obj = new $cls($n);
+      $changed = false;
+
+      if ($obj->value != $value) {
+        $obj->value = $value;
+        $changed = true;
+      }
+      if (isset($_POST["$ename:deleted:$n"]) &&
+          $_POST["$ename:deleted:$n"] == "on") {
+        $deleted = '1';
+      } else {
+        $deleted = '';
+      }
+      if ($obj->deleted != $deleted) {
+        $obj->deleted = $deleted;
+        $changed = true;
+      }
+
+      if ($changed) {
+        $CS = MTrackChangeset::begin("enum:$obj->tablename:$obj->name",
+            "changed $ename $obj->name");
+        $obj->save($CS);
+        $CS->commit();
+      }
+    }
+  }
+  header("Location: ${ABSWEB}admin/");
+  exit;
+}
+
+mtrack_head("Administration - $ename");
+
+echo "<form method='post'>";
+
+$cls = 'MTrack' . $ename;
+$obj = new $cls;
+echo "<br><b>$ename values</b><br>\n";
+$vals = $obj->enumerate(true);
+echo "<table><tr><th>Name</th><th>Value</th><th>Deleted</th></tr>\n";
+foreach ($vals as $V) {
+  $n = htmlentities($V['name'], ENT_QUOTES, 'utf-8');
+  $v = htmlentities($V['value'], ENT_QUOTES, 'utf-8');
+  $del = $V['deleted'] ? ' checked="checked" ' : '';
+  echo "<tr>" .
+    "<td>$n</td>" .
+    "<td><input type='text' name='$ename:value:$n' value='$v'></td>" .
+    "<td><input type='checkbox' name='$ename:deleted:$n' $del></td>" .
+    "</tr>\n";
+}
+echo "<tr>" .
+  "<td><input type='text' name='$ename:name:' value=''></td>" .
+  "<td><input type='text' name='$ename:value:' value=''></td>" .
+  "<td>Add a new $ename</td>" .
+  "</tr>\n";
+echo "</table>\n";
+
+echo "<button>Save Changes</button></form>";
+
+mtrack_foot();
+
diff --git a/web/admin/forkrepo.php b/web/admin/forkrepo.php
new file mode 100644 (file)
index 0000000..c06b724
--- /dev/null
@@ -0,0 +1,53 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+MTrackACL::requireAnyRights('Browser', 'fork');
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  $rid = $_POST['source'];
+  MTrackACL::requireAnyRights("repo:$rid", 'read');
+  $name = trim($_POST['name']);
+
+  if (strlen($name) == 0) {
+    throw new Exception("missing name");
+  }
+  if (preg_match("/[^a-zA-Z0-9_.-]/", $name)) {
+    throw new Exception("$name contains illegal characters");
+  }
+  $owner = mtrack_canon_username(MTrackAuth::whoami());
+  if (preg_match("/[^a-zA-Z0-9_.-]/", $owner)) {
+    throw new Exception("$owner must be a locally defined user");
+  }
+
+  $S = MTrackRepo::loadById($rid);
+  if (!$S->canFork()) {
+    throw new Exception("cannot fork this repo");
+  }
+  $P = new MTrackRepo;
+  $P->shortname = $name;
+  if (isset($_POST['repo:parent'])) {
+    // FIXME: ACL check to see if we're allowed to create under the specified
+    // parent
+    $P->parent = $_POST['repo:parent'];
+  } else {
+    $P->parent = "user:$owner";
+  }
+
+  $P->scmtype = $S->scmtype;
+  $P->description = $S->description;
+  $P->clonedfrom = $S->repoid;
+
+  $CS = MTrackChangeset::begin("repo:X",
+    "Clone repo $S->shortname as $P->shortname");
+  $P->save($CS);
+  $CS->setObject("repo:$P->repoid");
+  $CS->commit();
+  $name = $P->getBrowseRootName();
+  header("Location: ${ABSWEB}browse.php/$name");
+  exit;
+}
+
+header("Location: ${ABSWEB}browse.php");
+exit;
+
diff --git a/web/admin/group.php b/web/admin/group.php
new file mode 100644 (file)
index 0000000..2ad19b8
--- /dev/null
@@ -0,0 +1,87 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+if (!isset($_REQUEST['pid'])) {
+  throw new Exception("missing project id");
+}
+$pid = (int)$_REQUEST['pid'];
+
+MTrackACL::requireAnyRights("project:$pid", 'modify');
+
+$P = MTrackProject::loadById($pid);
+if (!$P) {
+  throw new Exception("invalid project " . htmlentities($pid));
+}
+
+if (isset($_REQUEST['group'])) {
+  $group = $_REQUEST['group'];
+} else {
+  $group = null;
+}
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  if (!strlen($group)) {
+    throw new Exception("missing group name");
+  }
+  if (isset($_POST['members'])) {
+    $members = $_POST['members'];
+  } else {
+    $members = array();
+  }
+
+  $CS = MTrackChangeset::begin("project:$pid", "Changed group $group");
+  if (isset($_POST['isnew'])) {
+    MTrackDB::q('insert into groups (name, project) values (?, ?)',
+      $group, $pid);
+  }
+
+  MTrackDB::q(
+    'delete from group_membership where groupname = ? and project = ?',
+    $group, $pid);
+  foreach ($members as $username) {
+    MTrackDB::q(
+      'insert into group_membership (groupname, project, username) values (?,?,?)',
+      $group, $pid, $username);
+  }
+  $CS->commit();
+  header("Location: {$ABSWEB}admin/project.php?edit=$pid");
+  exit;
+}
+
+mtrack_head($group ? "$P->name - $group" : "$P->name - New Group");
+
+echo "<form method='post'><input type='hidden' name='pid' value='$pid'>";
+if ($group) {
+  echo "<h1>" . htmlentities("$P->name - $group", ENT_QUOTES, 'utf-8') . "</h1>";
+  echo "<input type='hidden' name='group' value='" .
+    htmlentities($group, ENT_QUOTES, 'utf-8') .
+    "'>";
+} else {
+  echo "<h1>" . htmlentities("$P->name - New Group", ENT_QUOTES, 'utf-8') . "</h1>";
+  echo "Group: <input type='text' name='group'>";
+  echo "<input type='hidden' name='isnew' value='1'>";
+}
+
+$users = array();
+foreach (MTrackDB::q('select userid, fullname from userinfo where active = 1 order by userid')
+    ->fetchAll() as $row) {
+  if (strlen($row[1])) {
+    $disp = "$row[0] - $row[1]";
+  } else {
+    $disp = $row[0];
+  }
+  $users[$row[0]] = $disp;
+}
+$members = array();
+foreach (MTrackDB::q('select username from group_membership where project = ? and groupname = ?', $pid, $group)->fetchAll(PDO::FETCH_COLUMN, 0) as $name) {
+  $members[$name] = $name;
+}
+echo mtrack_multi_select_box('members', "Members", $users, $members);
+
+echo "<input type='submit' value='Save'>";
+
+echo "</form>";
+
+mtrack_foot();
+
diff --git a/web/admin/importcsv.php b/web/admin/importcsv.php
new file mode 100644 (file)
index 0000000..a79651a
--- /dev/null
@@ -0,0 +1,325 @@
+<?php # vim:ts=2:sw=2:et:
+include '../../inc/common.php';
+
+MTrackACL::requireAllRights("Tickets", 'create');
+session_start();
+
+$field_aliases = array(
+  'state' => 'status',
+  'pri' => 'priority',
+  'id' => 'ticket',
+  'type' => 'classification',
+);
+$supported_fields = array(
+  'classification',
+  'ticket',
+  'milestone',
+  '-milestone',
+  '+milestone',
+  'summary',
+  'status',
+  'priority',
+  'owner',
+  'type',
+  'component',
+  '-component',
+  '+component',
+  'description'
+);
+foreach ($supported_fields as $i => $f) {
+  unset($supported_fields[$i]);
+  $supported_fields[$f] = $f;
+}
+
+$C = MTrackTicket_CustomFields::getInstance();
+foreach ($C->fields as $f) {
+  $name = substr($f->name, 2);
+  $supported_fields[$f->name] = $f->name;
+  if (!isset($field_aliases[$name])) {
+    $field_aliases[$name] = $f->name;
+  }
+}
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+
+  if (isset($_FILES['csvfile']) && $_FILES['csvfile']['error'] == 0
+      && is_uploaded_file($_FILES['csvfile']['tmp_name'])) {
+    ini_set('auto_detect_line_endings', true);
+    $fp = fopen($_FILES['csvfile']['tmp_name'], 'r');
+    $header = fgetcsv($fp);
+    $err = array();
+    $output = array();
+    foreach ($header as $i => $name) {
+      $name = strtolower($name);
+      if (isset($field_aliases[$name])) {
+        $name = $field_aliases[$name];
+      }
+      if (!isset($supported_fields[$name])) {
+        $err[] = "Unsupported field: $name";
+      }
+      $header[$i] = $name;
+    }
+    $db = MTrackDB::get();
+    $db->beginTransaction();
+    MTrackChangeset::$use_txn = false;
+    $todo = array();
+    do {
+      $line = fgetcsv($fp);
+      if ($line === false) break;
+
+      $item = array();
+      foreach ($header as $i => $name) {
+        $item[$name] = $line[$i];
+      }
+
+      if (isset($item['ticket'])) {
+        $id = $item['ticket'];
+        if ($id[0] == '#') {
+          $id = substr($id, 1);
+        }
+        try {
+          $tkt = MTrackIssue::loadByNSIdent($id);
+          if ($tkt == null) {
+            $err[] = "No such ticket $id";
+            continue;
+          }
+        } catch (Exception $e) {
+          $err[] = $e->getMessage();
+          continue;
+        }
+        $output[] = "<b>Updating ticket $tkt->nsident</b><br>\n";
+      } else {
+        $tkt = new MTrackIssue;
+        $tkt->priority = 'normal';
+        list($tkt->nsident) = MTrackDB::q(
+          'select max(cast(nsident as integer)) + 1 from tickets')
+          ->fetchAll(PDO::FETCH_COLUMN, 0);
+        if ($tkt->nsident === null) {
+          $tkt->nsident = 1;
+        }
+        $output[] = "<b>Creating ticket $tkt->nsident<b><br>\n";
+      }
+      $CS = MTrackChangeset::begin("ticket:X", $_POST['comment']);
+      if (strlen(trim($_POST['comment']))) {
+        $tkt->addComment($_POST['comment']);
+      }
+      foreach ($item as $name => $value) {
+        if ($name == 'ticket') {
+          continue;
+        }
+        $output[] = "$name => $value<br>\n";
+        try {
+          switch ($name) {
+            case 'summary':
+            case 'description':
+            case 'classification':
+            case 'priority':
+            case 'severity':
+            case 'changelog':
+            case 'owner':
+            case 'cc':
+              $tkt->$name = strlen($value) ? $value : null;
+              break;
+            case 'milestone':
+              if (strlen($value)) {
+                foreach ($tkt->getMilestones() as $mid) {
+                  $tkt->dissocMilestone($mid);
+                }
+                $tkt->assocMilestone($value);
+              }
+              break;
+            case '+milestone':
+              if (strlen($value)) {
+                $tkt->assocMilestone($value);
+              }
+              break;
+            case '-milestone':
+              if (strlen($value)) {
+                $tkt->dissocMilestone($value);
+              }
+              break;
+            case 'component':
+              if (strlen($value)) {
+                foreach ($tkt->getComponents() as $mid) {
+                  $tkt->dissocComponent($mid);
+                }
+                $tkt->assocComponent($value);
+              }
+              break;
+            case '+component':
+              if (strlen($value)) {
+                $tkt->assocComponent($value);
+              }
+              break;
+            case '-component':
+              if (strlen($value)) {
+                $tkt->dissocComponent($value);
+              }
+              break;
+            default:
+              if (!strncmp($name, 'x_', 2)) {
+                $tkt->{$name} = $value;
+              }
+              break;
+          }
+        } catch (Exception $e) {
+          $err[] = $e->getMessage();
+        }
+      }
+      $tkt->save($CS);
+      $CS->setObject("ticket:" . $tkt->tid);
+
+    } while (true);
+    $_SESSION['admin.import.result'] = array($err, $output);
+    if (count($err)) {
+      $db->rollback();
+    } else {
+      $db->commit();
+    }
+  }
+  header("Location: {$ABSWEB}admin/importcsv.php");
+  exit;
+}
+
+if (isset($_SESSION['admin.import.result'])) {
+  list($err, $info) = $_SESSION['admin.import.result'];
+  unset($_SESSION['admin.import.result']);
+
+  mtrack_head(count($err) ? 'Import Failed' : 'Import Complete');
+
+  foreach ($info as $line) {
+    echo $line;
+  }
+
+  if (count($err)) {
+    echo "The following errors were encountered:<br>\n";
+    foreach ($err as $msg) {
+      echo htmlentities($msg) . "<br>\n";
+    }
+    echo "<br><b>No changes were committed</b><br>\n";
+  } else {
+    echo "<br><b>Done!</b>\n";
+  }
+
+  mtrack_foot();
+  exit;
+}
+
+mtrack_head('Import');
+
+?>
+<h1>Import/Update via CSV</h1>
+
+<p>
+You may use this facility to change ticket properties en-masse by uploading
+a CSV file.
+</p>
+
+<ul>
+  <li>If a ticket column is present and non-empty,
+    that ticket will be updated</li>
+  <li>If there is no ticket column, or the ticket column is empty,
+    then a ticket will be created</li>
+  <li>If any errors are detected, none of the changes from the CSV file
+    will be applied</li>
+</ul>
+
+<p>
+The input file must be a CSV file with the field names on the first line.
+</p>
+
+<p>
+The following fields are supported:
+</p>
+
+<dl>
+  <dt>ticket</dt>
+  <dd>The ticket number</dd>
+
+  <dt>milestone</dt>
+  <dd>The value to use for the milestone.  If updating an existing ticket,
+   this field will remove any other milestones in the ticket and set it to
+   only this value.
+  </dd>
+
+  <dt>-milestone</dt>
+  <dd>Removes a milestone; if the ticket is associated with the named milestone,
+   it will be removed from that milestone.
+  </dd>
+
+  <dt>+milestone</dt>
+  <dd>Associates the ticket with the named milestone, preserving any other
+  milestones currently associated with the ticket.
+  </dd>
+
+  <dt>summary</dt>
+  <dd>Sets the summary for the ticket</dd>
+
+  <dt>status or state</dt>
+  <dd>Sets the state of the ticket; can be one of the configured ticket states
+  </dd>
+
+  <dt>priority</dt>
+  <dd>Sets the priority; can be one of the configured priorities</dd>
+
+  <dt>owner</dt>
+  <dd>Sets the owner</dd>
+
+  <dt>type</dt>
+  <dd>Sets the ticket type</dd>
+
+  <dt>component</dt>
+  <dd>Sets the component, replacing all other component associations</dd>
+
+  <dt>-component</dt>
+  <dd>Removes association with the named component</dd>
+
+  <dt>+component</dt>
+  <dd>Associates with the named component, preserving existing associations</dd>
+
+  <dt>description</dt>
+  <dd>Sets the description of the ticket</dd>
+
+<?php
+
+foreach ($C->fields as $f) {
+  $name = substr($f->name, 2);
+  if (!isset($field_aliases[$name]) || $field_aliases[$name] != $f->name) {
+    $name = $f->name;
+    echo "<dt>$name</dt>\n";
+  } else {
+    echo "<dt>$name</dt>\n";
+    echo "<dt>$f->name</dt>\n";
+  }
+  echo "<dd>" . htmlentities($f->label, ENT_QUOTES, 'utf-8') . "\n";
+
+  if ($f->type == 'select') {
+    echo "<br>Value may be one of:<br>";
+    $data = $f->ticketData();
+    foreach ($data['options'] as $opt) {
+      echo " <tt>" . htmlentities($opt, ENT_QUOTES, 'utf-8') . "</tt><br>";
+    }
+  }
+
+  echo "</dd>\n";
+}
+
+?>
+
+</dl>
+
+<h2>Import</h2>
+
+<p>Enter a comment in the box below; it will be added as a comment to
+all affected tickets</p>
+
+<form method='post' enctype='multipart/form-data'>
+  <textarea name='comment' id='comment'
+    class='code wiki' rows='4' cols='78'></textarea>
+  <input type='file' name='csvfile'>
+  <input type='submit' value='Import'>
+</form>
+
+<?php
+mtrack_foot();
+
diff --git a/web/admin/index.php b/web/admin/index.php
new file mode 100644 (file)
index 0000000..e6ac6a3
--- /dev/null
@@ -0,0 +1,69 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+mtrack_head("Administration");
+
+$cat_titles = array(
+  'tickets' => 'Configure Tickets',
+  'projects' => 'Configure Projects &amp; Notifications',
+  'repo' => 'Configure Repositories',
+  'user' => 'User Administration &amp; Authentication',
+  'logs' => 'Review Logs',
+);
+
+$by_cat = array();
+
+function add_cat($url) {
+  global $by_cat;
+  $cats = func_get_args();
+  array_shift($cats);
+  foreach ($cats as $cat) {
+    $by_cat[$cat][] = $url;
+  }
+}
+
+if (MTrackACL::hasAnyRights('Projects', 'modify')) {
+  add_cat("<a href='{$ABSWEB}admin/project.php'>Projects</a> and their notification settings", 'projects');
+}
+
+if (MTrackACL::hasAnyRights('Enumerations', 'modify')) {
+  $eurl = $ABSWEB . 'admin/enum.php';
+  add_cat("<a href='$eurl/Priority'>Priority</a>, <a href='$eurl/TicketState'>TicketState</a>, <a href='$eurl/Severity'>Severity</a>, <a href='$eurl/Resolution'>Resolution</a> and <a href='$eurl/Classification'>Classification</a> fields used in tickets", 'tickets');
+  add_cat("<a href='{$ABSWEB}admin/customfield.php'>Custom Fields</a>", 'tickets');
+}
+
+if (MTrackACL::hasAnyRights('Components', 'modify')) {
+  add_cat("<a href='{$ABSWEB}admin/component.php'>Components</a> and their associations with Projects", 'tickets', 'projects');
+}
+
+if (MTrackACL::hasAnyRights('Tickets', 'create')) {
+  add_cat("<a href='{$ABSWEB}admin/importcsv.php'>Import Tickets</a> from a CSV file", 'tickets');
+}
+
+if (MTrackACL::hasAnyRights('Browser', 'modify')) {
+  add_cat("Configure <a href='{$ABSWEB}admin/repo.php'>Repositories</a> and their links to Projects", 'repo');
+}
+
+if (MTrackACL::hasAllRights('User', 'modify')) {
+  add_cat("Administer <a href='{$ABSWEB}admin/auth.php'>Authentication</a>", 'user');
+  add_cat("Administer <a href='{$ABSWEB}admin/user.php'>Users</a>", 'user');
+}
+
+if (MTrackACL::hasAllRights('Browser', 'modify')) {
+  add_cat("<a href='{$ABSWEB}admin/logs.php'>Indexer logs</a>", 'logs');
+}
+
+foreach ($cat_titles as $cat => $title) {
+  $links = $by_cat[$cat];
+  if (count($links) == 0) {
+    continue;
+  }
+  echo "<h2>$title</h2>";
+  foreach ($links as $link) {
+    echo $link, "<br>\n";
+  }
+}
+
+mtrack_foot();
+
diff --git a/web/admin/logs.php b/web/admin/logs.php
new file mode 100644 (file)
index 0000000..f94080d
--- /dev/null
@@ -0,0 +1,62 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+MTrackACL::requireAnyRights('Browser', 'modify');
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  if (isset($_POST['reset'])) {
+    MTrackDB::q('delete from search_engine_state');
+  }
+  header("Location: {$ABSWEB}admin/logs.php");
+  exit;
+}
+
+mtrack_head("Logs");
+
+$vardir = MTrackConfig::get('core', 'vardir');
+$filename = "$vardir/indexer.log";
+
+
+echo "<h1>Indexer Log</h1>\n";
+echo "<tt>$filename</tt><br>\n";
+$mtime = filemtime($filename);
+if ($mtime) {
+  echo "Modified: " . mtrack_date("@$mtime", true) . "<br>";
+}
+
+$last = null;
+foreach (MTrackDB::q('select last_run from search_engine_state')->fetchAll()
+    as $row) {
+  $last = $row[0];
+}
+if ($last === null) {
+  echo "No objects have been indexed yet\n";
+} else {
+  echo "Last Indexed Object: " . mtrack_date($last, true) . "<br>\n";
+}
+
+if ($mtime) {
+  $fp = fopen($filename, 'r');
+  $lines = array();
+  while (($line = fgets($fp)) !== false) {
+    $lines[] = htmlentities($line, ENT_QUOTES, 'utf-8');
+    if (count($lines) > 100) {
+      array_shift($lines);
+    }
+  }
+  echo "<pre>";
+  foreach ($lines as $line) {
+    echo $line;
+  }
+  echo "</pre>";
+}
+?>
+<form method='post'>
+  <button type='submit' name='reset'
+    >Rebuild Index from scratch on next run</button>
+</form>
+<?php
+
+mtrack_foot();
+
diff --git a/web/admin/project.php b/web/admin/project.php
new file mode 100644 (file)
index 0000000..617032e
--- /dev/null
@@ -0,0 +1,214 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+MTrackACL::requireAnyRights('Projects', 'modify');
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  if (isset($_POST['cancel'])) {
+    header("Location: ${ABSWEB}admin/");
+    exit;
+  }
+
+  $pid = $_GET['edit'];
+  if ($pid == 'new') {
+    MTrackACL::requireAnyRights('Projects', 'create');
+    $P = new MTrackProject;
+  } else {
+    $P = MTrackProject::loadById($pid);
+    if (!$P) {
+      throw new Exception("invalid project " . htmlentities($pid));
+    }
+    MTrackACL::requireAnyRights("project:$pid", 'modify');
+  }
+
+  $P->name = $_POST["name"];
+  $P->shortname = $_POST["shortname"];
+  $P->ordinal = $_POST["ordinal"];
+  $P->notifyemail = $_POST["email"];
+  $CS = MTrackChangeset::begin("project:X",
+      $pid == 'new' ?
+      "added project $P->name" :
+      "edit project $P->name");
+  $P->save($CS);
+
+  if (MTrackACL::hasAnyRights('Components', 'modify')) {
+    MTrackDB::q('delete from components_by_project where projid = ?', $P->projid);
+    if (isset($_POST['components'])) {
+      $comps = $_POST['components'];
+      foreach ($comps as $cid) {
+        MTrackDB::q(
+            'insert into components_by_project (compid, projid) values (?, ?)',
+            $cid, $P->projid);
+      }
+    }
+  }
+
+  $CS->setObject("project:$P->projid");
+  if (isset($_POST['perms'])) {
+    MTrackACL::setACL("project:$P->projid", 0, json_decode($_POST['perms']));
+  }
+  $CS->commit();
+
+  header("Location: ${ABSWEB}admin/project.php");
+  exit;
+}
+
+mtrack_head("Administration - Projects");
+
+?>
+<h1>Projects</h1>
+<p>
+Projects can be created to track development on a per-project or per-product
+basis.  Components may be associated with a project, as well as a default
+email distribution address.
+</p>
+<?php
+
+if (isset($_GET['edit'])) {
+  $pid = $_GET['edit'];
+  if ($pid != 'new') {
+    $q = MTrackDB::q('select * from projects where projid = ?', $pid);
+    $p = null;
+    foreach ($q as $row) {
+      $p = $row;
+    }
+    if ($p == null) {
+      throw new Exception("no such project " . htmlentities($pid));
+    }
+  } else {
+    $p = array(
+      'projid' => 'new',
+      'name' => 'My New Project',
+      'shortname' => 'newproject',
+      'ordinal' => 5,
+      'notifyemail' => null
+    );
+  }
+  echo "<form method='post' action=\"{$ABSWEB}admin/project.php?edit=$pid\">";
+
+  echo "<table>";
+  $name = htmlentities($p['name'], ENT_QUOTES, 'utf-8');
+  $sname = htmlentities($p['shortname'], ENT_QUOTES, 'utf-8');
+  $ord = htmlentities($p['ordinal'], ENT_QUOTES, 'utf-8');
+  $email = htmlentities($p['notifyemail'], ENT_QUOTES, 'utf-8');
+  echo "<tr><th>Name</th>",
+    "<td><input type='text' name='name' value='$name'></td></tr>";
+  echo "<tr><th>Short Name</th>",
+    "<td><input type='text' name='shortname' value='$sname'></td></tr>";
+  echo "<tr><th>Sorting</th>",
+    "<td><input type='text' name='ordinal' value='$ord'></td></tr>";
+  echo "<tr><th>Group Email Address</th>",
+    "<td><input type='text' name='email' value='$email'></td></tr>";
+  echo "</table>";
+
+  if (MTrackACL::hasAnyRights('Components', 'modify')) {
+    $components = array();
+    foreach (MTrackDB::q(
+        'select compid, name, deleted from components order by name')
+        ->fetchAll() as $row) {
+      if ($row[2]) {
+        $row[1] .= " (deleted)";
+      }
+      $components[$row[0]] = $row[1];
+    }
+    $p_by_c = array();
+    if ($pid != 'new') {
+      foreach (MTrackDB::q(
+          'select compid from components_by_project where projid = ?', $pid)
+          ->fetchAll() as $row) {
+        $p_by_c[$row[0]] = $row[0];
+      }
+    }
+    echo "<h2>Components</h2>";
+    echo "<p>Associate component(s) with this project</p>";
+    echo mtrack_multi_select_box('components', "(select to add)",
+        $components, $p_by_c);
+  }
+
+  $repos = array();
+  foreach (MTrackDB::q('select distinct r.repoid, shortname from project_repo_link p left join repos r on p.repoid = r.repoid where projid = ?', (int)$pid) as $row) {
+    $repos[$row[0]] = $row[1];
+  }
+  foreach (MTrackDB::q("select repoid, shortname from repos where parent = 'project:' || ?", $p['shortname']) as $row) {
+    $repos[$row[0]] = $row[1];
+  }
+
+  if ($pid != 'new') {
+    echo "<h2>Groups</h2>";
+    echo "<p>The following groups are associated with this project. You may assign permissions to groups to make it easier to manage groups of users.</p>";
+
+    foreach (MTrackDB::q('select name from groups where project = ?', $pid)
+        as $row) {
+      echo "<a href='{$ABSWEB}admin/group.php?pid=$pid&amp;group=$row[0]'>"
+       . htmlentities($row[0], ENT_QUOTES, 'utf-8') . '</a><br>';
+    }
+
+    echo "<a class='button' href=\"{$ABSWEB}admin/group.php?pid=$pid\">New Group</a>";
+  }
+
+  echo "<h2>Linked Repositories</h2>";
+  if (count($repos)) {
+    echo "<ul>\n";
+    foreach ($repos as $rid => $name) {
+      echo "<li><a href=\"{$ABSWEB}admin/repo.php/$rid\">" .
+        htmlentities($name, ENT_QUOTES, 'utf-8') . "</a></li>\n";
+    }
+    echo "</ul>\n";
+  } else {
+    echo "<i>No linked repositories</i>\n";
+  }
+  echo "<br><br>\n";
+
+  if (MTrackACL::hasAnyRights("project:$pid", 'modify')) {
+    $action_map = array(
+      'Admin' => array(
+        'modify' => 'Administer via web UI',
+      ),
+    );
+
+    MTrackACL::renderACLForm('perms', "project:$pid", $action_map);
+  }
+
+  echo "<button type='submit'>Save</button>";
+  echo "<button type='submit' name='cancel'>Cancel</button>";
+
+  echo "</form>";
+} else {
+?>
+<p>
+Select a project below to edit it, or click the "Add" button to create
+a new project.
+</p>
+<?php
+
+  echo "<table>\n";
+  foreach (MTrackDB::q(
+      'select projid, name, shortname, ordinal, notifyemail
+      from projects order by ordinal') as $row) {
+
+    $pid = $row[0];
+    $name = htmlentities($row[1], ENT_QUOTES, 'utf-8');
+    $sname = htmlentities($row[2], ENT_QUOTES, 'utf-8');
+    if ($sname != $name) {
+      $sname = " ($sname)";
+    } else {
+      $sname = '';
+    }
+    $email = htmlentities($row[4], ENT_QUOTES, 'utf-8');
+
+    echo "<tr>",
+      "<td><a href=\"{$ABSWEB}admin/project.php?edit=$pid\">$name$sname</a></td>",
+      "<td>$email</td>",
+      "</tr>\n";
+
+  }
+  echo "</table><br>";
+
+  echo "<form method='get' action=\"{$ABSWEB}admin/project.php\">";
+  echo "<input type='hidden' name='edit' value='new'>";
+  echo "<button type='submit'>Add Project</button></form>";
+}
+
+mtrack_foot();
+
diff --git a/web/admin/repo.php b/web/admin/repo.php
new file mode 100644 (file)
index 0000000..44d6d99
--- /dev/null
@@ -0,0 +1,282 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+$rid = mtrack_get_pathinfo();
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  if ($rid == 'new') {
+    MTrackACL::requireAnyRights('Browser', array('create', 'fork'));
+    $P = new MTrackRepo;
+  } else {
+    MTrackACL::requireAnyRights("repo:$rid", 'modify');
+    $P = MTrackRepo::loadById($rid);
+  }
+  $links = $P->getLinks();
+  $plinks = array();
+
+  foreach ($_POST as $name => $value) {
+    if (preg_match("/^link:(\d+|new):project$/", $name, $M)) {
+      $lid = $M[1];
+      $plinks[$lid] = array(
+        (int)$_POST["link:$lid:project"],
+        trim($_POST["link:$lid:regex"]));
+    }
+  }
+  if (isset($plinks['new'])) {
+    $n = $plinks['new'];
+    unset($plinks['new']);
+    if (strlen($n[1])) {
+      $P->addLink($n[0], $n[1]);
+    }
+  }
+  foreach ($plinks as $lid => $n) {
+    if (isset($links[$lid])) {
+      if ($n != $links[$lid] || !strlen($n[1])) {
+        $P->removeLink($lid);
+        if (strlen($n[1])) {
+          $P->addLink($n[0], $n[1]);
+        }
+      }
+    } else if (strlen($n[1])) {
+      $P->addLink($n[0], $n[1]);
+    }
+  }
+
+  $restricted = !MTrackACL::hasAnyRights('Browser', 'create');
+  if ($rid == 'new') {
+    if (isset($_POST['repo:name'])) {
+      $P->shortname = $_POST["repo:name"];
+    }
+    if (isset($_POST['repo:type'])) {
+      $P->scmtype = $_POST["repo:type"];
+    }
+    if (isset($_POST['repo:path'])) {
+      if ($restricted) throw new Exception("cannot set the repo path");
+      $P->repopath = $_POST["repo:path"];
+    }
+    if (isset($_POST['repo:parent']) && strlen($_POST['repo:parent'])) {
+      $P->parent = $_POST["repo:parent"];
+    }
+  } else {
+    $editable = !strlen($P->parent);
+
+    if (isset($_POST['repo:name']) && $_POST['repo:name'] != $P->shortname) {
+      if (!$editable) throw new Exception("cannot change the repo name");
+      $P->shortname = $_POST["repo:name"];
+    }
+    if (isset($_POST['repo:type']) && $_POST['repo:type'] != $P->scmtype) {
+      if (!$editable) throw new Exception("cannot change the repo type");
+      $P->scmtype = $_POST["repo:type"];
+    }
+    if (isset($_POST['repo:path']) && $_POST['repo:path'] != $P->repopath) {
+      if (!$editable) throw new Exception("cannot change the repo path");
+      $P->repopath = $_POST["repo:path"];
+    }
+    if (isset($_POST['repo:parent']) && $_POST['repo:parent'] != $P->parent) {
+      if (!$editable) throw new Exception("cannot change the repo parent");
+      $P->parent = $_POST["repo:parent"];
+    }
+  }
+  if (isset($_POST["repo:description"])) {
+    $P->description = $_POST["repo:description"];
+  }
+
+  $CS = MTrackChangeset::begin("repo:$rid", "Edit repo $P->shortname");
+  $P->save($CS);
+  $CS->setObject("repo:$P->repoid");
+
+  if (isset($_POST['perms'])) {
+    $perms = json_decode($_POST['perms']);
+    MTrackACL::setACL("repo:$P->repoid", 0, $perms);
+  }
+
+  $CS->commit();
+  header("Location: ${ABSWEB}browse.php/" . $P->getBrowseRootName());
+  exit;
+}
+
+mtrack_head("Administration - Repositories");
+if (!strlen($rid)) {
+  MTrackACL::requireAnyRights('Browser', 'modify');
+?>
+<h1>Repositories</h1>
+
+<p>
+Repositories are version controlled folders that remember your files and
+folders at various points in time.  Mtrack has support for multiple different
+Software Configuration Management systems (also known as Version Control
+Systems; SCM and VCS are the common acronyms).
+</p>
+<p>
+Listed below are the repositories that mtrack is configured to use.
+The <em>wiki</em> repository is treated specially by mtrack; it stores the
+wiki pages.  Click on the repository name to edit it, or click on the "Add"
+button to tell mtrack to use another repository.
+</p>
+<ul>
+<?php
+  foreach (MTrackDB::q(
+      'select repoid, shortname, parent from repos order by parent, shortname')
+      as $row) {
+    $rid = $row[0];
+    if (MTrackACL::hasAnyRights("repo:$rid", 'modify')) {
+      $name = MTrackSCM::makeDisplayName($row);
+      $name = htmlentities($name, ENT_QUOTES, 'utf-8');
+      echo "<li><a href='{$ABSWEB}admin/repo.php/$rid'>$name</a></li>\n";
+    }
+  }
+  echo "</ul>";
+  if (MTrackACL::hasAnyRights('Browser', 'create')) {
+    echo "<a href='{$ABSWEB}admin/repo.php/new'>Add new repo</a><br>\n";
+  }
+  mtrack_foot();
+  exit;
+}
+
+$repotypes = array();
+foreach (MTrackRepo::getAvailableSCMs() as $t => $r) {
+  $d = $r->getSCMMetaData();
+  $repotypes[$t] = $d['name'];
+}
+
+echo "<form method='post'>";
+
+if ($rid == 'new') {
+  MTrackACL::requireAnyRights('Browser', 'create');
+?>
+<h2>Add new or existing Repository</h2>
+<p>
+  Use the form below to tell mtrack where to find an existing
+  repository and add it to its list.  Leave the "Path" field
+  blank to create a new repository.
+</p>
+<table>
+<?php
+  echo "<tr><th>Name</th>" .
+    "<td><input type='text' name='repo:name' value=''></td>" .
+    "</tr>";
+  echo "<tr><th>Type</th>" .
+    "<td>" .
+    mtrack_select_box("repo:type", $repotypes, null, true) .
+    "</td></tr>\n";
+  echo "<tr><th>Path</th>" .
+    "<td><input type='text' name='repo:path' size='50' value=''></td>" .
+    "</tr>\n";
+  echo "<tr><td colspan='2'>Description<br><em>You may use <a href='{$ABSWEB}help.php/WikiFormatting' target='_blank'>WikiFormatting</a></em><br>\n";
+  echo "<textarea name='repo:description' class='wiki shortwiki' rows='5' cols='78'>";
+  echo "</textarea></td></tr>\n";
+  echo "</table>";
+} else {
+  $P = MTrackRepo::loadById($rid);
+  MTrackACL::requireAnyRights("repo:$P->repoid", 'modify');
+
+  $name = htmlentities($P->shortname, ENT_QUOTES, 'utf-8');
+  $type = htmlentities($P->scmtype, ENT_QUOTES, 'utf-8');
+  $path = htmlentities($P->repopath, ENT_QUOTES, 'utf-8');
+  $desc = htmlentities($P->description, ENT_QUOTES, 'utf-8');
+
+  echo "<h2>Repository: $name</h2>\n";
+  echo "<table>\n";
+
+  if (!$P->parent) {
+    /* not created/managed by us; some fields are editable */
+    $name = "<input type='text' name='repo:name' value='$name'>";
+    $type = mtrack_select_box("repo:type", $repotypes, $type);
+    $path = "<input type='text' name='repo:path' size='50' value='$path'>";
+  } else {
+    $name = htmlentities($P->getBrowseRootName(), ENT_QUOTES, 'utf-8');
+  }
+
+  echo "<tr><th>Name</th><td>$name</td></tr>";
+  echo "<tr><th>Type</th><td>$type</td></tr>\n";
+  echo "<tr><th>Path</th><td>$path</td></tr>\n";
+  echo "<tr><td colspan='2'>Description<br><em>You may use <a href='{$ABSWEB}help.php/WikiFormatting' target='_blank'>WikiFormatting</a></em><br>\n";
+  echo "<textarea name='repo:description' class='wiki shortwiki' rows='5' cols='78'>$desc";
+  echo "</textarea></td></tr>\n";
+
+  echo "<tr><td colspan='2'>\n";
+
+  $action_map = array(
+    'Web' => array(
+      'read'   => 'Browse via web UI',
+      'modify' => 'Administer via web UI',
+      'delete' => 'Delete repo via web UI',
+    ),
+    'SSH' => array(
+      'checkout' => 'Check-out repo via SSH',
+      'commit' => 'Commit changes to repo via SSH',
+    ),
+  );
+
+  MTrackACL::renderACLForm('perms', "repo:$P->repoid", $action_map);
+
+  echo "</tr>\n";
+  echo "</table>";
+}
+
+$projects = array();
+foreach (MTrackDB::q('select projid, name, shortname from projects
+    order by name')->fetchAll() as $row) {
+  if ($row[1] != $row[2]) {
+    $projects[$row[0]] = $row[1] . " ($row[2])";
+  } else {
+    $projects[$row[0]] = $row[1];
+  }
+}
+
+if (count($projects)) {
+
+  echo <<<HTML
+<h3>Linked Projects</h3>
+<p>
+Project links help associate code changes made in a repository with a project,
+and this in turn helps mtrack decide who to notify about the change.
+</p>
+<p>
+When assessing a change, mtrack will try each regex listed below and then take
+the project that corresponds with the longest match--not the longest pattern;
+the longest actual match.
+</p>
+<p>
+The regex should just be the bare regex string--you must not enclose it in
+regex delimiters.
+</p>
+<p>
+You can remove a link by setting the regex to the empty string.
+</p>
+HTML;
+
+  echo "<table>";
+  echo "<tr><th>Regex</th><th>Project</th></tr>\n";
+
+  if ($rid != 'new') {
+    foreach ($P->getLinks() as $lid => $n) {
+      list($pid, $regex) = $n;
+
+      $regex = htmlentities($regex, ENT_QUOTES, 'utf-8');
+      echo "<tr><td>" .
+        "<input type='text' name='link:$lid:regex' value='$regex'></td>".
+        "<td>" . mtrack_select_box("link:$lid:project", $projects, $pid) .
+        "</td></tr>\n";
+    }
+  }
+
+  if ($rid == 'new') {
+    $newre = '/';
+  } else {
+    $newre = '';
+  }
+
+  echo "<tr><td>" .
+    "<input type='text' name='link:new:regex' value='$newre'></td>".
+    "<td>" . mtrack_select_box("link:new:project", $projects) .
+    "</td><td>Add new link</td></tr>\n";
+
+  echo "</table>";
+}
+
+echo "<button>Save Changes</button></form>";
+
+mtrack_foot();
+
diff --git a/web/admin/user.php b/web/admin/user.php
new file mode 100644 (file)
index 0000000..3000cce
--- /dev/null
@@ -0,0 +1,76 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+/* Note: individual user editing is carried out in web/user.php */
+
+MTrackACL::requireAnyRights('User', 'modify');
+
+mtrack_head("Administration - Users");
+
+?>
+<h1>Users</h1>
+<?php
+  echo "<form method='get' action=\"{$ABSWEB}admin/user.php\">";
+  $find = htmlentities(trim($_GET['find']), ENT_QUOTES, 'utf-8');
+?>
+<p>To find an user, enter their name, userid or email address in the box
+below and click search; matches will be shown in the list below.
+</p>
+<input type="text" name="find" value="<?php echo $find ?>">
+<button type="submit">Find User</button>
+</form>
+<p>
+Select a user below to edit them, or click the "Add" button to create
+a new user.
+</p>
+
+<?php
+
+$limit = 15;
+$offset = isset($_GET['off']) ? (int)$_GET['off'] : 0;
+
+if (strlen($find)) {
+  $sql =
+    "select distinct i.userid, fullname, email, active from userinfo i left join useraliases a on i.userid = a.userid where i.userid like '%$find%' or fullname like '%$find%' or email like '%$find%' or a.alias like '%$find%' order by active desc, i.userid limit $limit offset $offset";
+} else {
+  $sql = "select userid, fullname, email, active from userinfo order by case active when 1 then 0 else 1 end, userid limit $limit offset $offset";
+}
+
+echo "<table>\n";
+foreach (MTrackDB::q($sql) as $row) {
+
+  $uid = $row[0];
+  $name = htmlentities($row[1], ENT_QUOTES, 'utf-8');
+  $email = htmlentities($row[2], ENT_QUOTES, 'utf-8');
+  $class = $row[3] == '1' ? 'activeuser' : 'inactiveuser';
+
+  echo "<tr class='$class'>",
+    "<td>" . mtrack_username($uid, array('edit' => 1)) . "</td>" .
+    "<td>$name</td>",
+    "<td>$email</td>",
+    "</tr>\n";
+
+}
+echo "</table><br>";
+if ($offset > 0) {
+  echo "<a href=\"{$ABSWEB}admin/user.php?off=" . ($offset - $limit) . "\">Previous</a> ";
+}
+echo "<a href=\"{$ABSWEB}admin/user.php?off=" . ($offset + $limit) . "\">Next</a>";
+echo "<br><br>";
+
+echo "<h2>Add User</h2>";
+echo "<form method='get' action=\"{$ABSWEB}user.php\">";
+?>
+<input type="hidden" name="edit" value="1">
+<p>
+To create a new user, enter the userid (typically the "short" login name) that
+you want to use in the box below, and click "Create".
+</p>
+<input type="text" name="user" value="">
+<button type="submit">Create User</button>
+</form>
+<?php
+
+mtrack_foot();
+
diff --git a/web/admin/watch.php b/web/admin/watch.php
new file mode 100644 (file)
index 0000000..21d9d97
--- /dev/null
@@ -0,0 +1,31 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../../inc/common.php';
+
+$me = mtrack_canon_username(MTrackAuth::whoami());
+
+if ($me == 'anonymous' || MTrackAuth::getUserClass() == 'anonymous') {
+  exit;
+}
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  $object = $_GET['o'];
+  $id = $_GET['i'];
+  $v = $_POST['w'];
+  $value = json_decode($v);
+
+  $db = MTrackDB::get();
+  $db->beginTransaction();
+  MTrackDB::q('delete from watches where otype = ? and oid = ? and userid = ?',
+    $object, $id, $me);
+
+  foreach ($value as $medium => $events) {
+    foreach ($events as $evt => $value) {
+      MTrackDB::q('insert into watches (otype, oid, userid, medium, event, active) values (?, ?, ?, ?, ?, 1)',
+        $object, $id, $me, $medium, $evt);
+    }
+  }
+
+  $db->commit();
+}
+
diff --git a/web/attachment.php b/web/attachment.php
new file mode 100644 (file)
index 0000000..82a2226
--- /dev/null
@@ -0,0 +1,42 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+$pi = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '';
+$vars = explode('/', $pi);
+array_shift($vars);
+$filename = array_pop($vars);
+$cid = array_pop($vars);
+$object = join('/', $vars);
+
+MTrackACL::requireAllRights($object, 'read');
+
+foreach (MTrackDB::q('select hash, size from attachments where
+    object = ? and cid = ? and filename = ?', $object, $cid, $filename)
+    ->fetchAll() as $row)
+{
+  $filename = basename($filename);
+  header("Pragma: public");
+  header('Expires: 0');
+  header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
+  header('Cache-Control: private', false);
+  $path = MTrackAttachment::local_path($row['hash']);
+  $mimetype = mtrack_mime_detect($path, $filename);
+  header("Content-Type: $mimetype");
+
+  list($major) = explode('/', $mimetype, 2);
+  if ($major == 'image' || $major == 'text') {
+    $disp = 'inline';
+  } else {
+    $disp = 'attachment';
+  }
+  header("Content-Disposition: $disp; filename=\"$filename\"");
+  header('Content-Transfer-Encoding: binary');
+  header("Content-Length: $row[size]");
+  readfile($path);
+  exit;
+}
+
+mtrack_header('Not found');
+mtrack_foot();
diff --git a/web/avatar.php b/web/avatar.php
new file mode 100644 (file)
index 0000000..52271f5
--- /dev/null
@@ -0,0 +1,213 @@
+<?php # vim:ts=2:sw=2:et:
+include '../inc/common.php';
+
+$username = $_GET['u'];
+$size = $_GET['s'];
+$data = MTrackAuth::getUserData($username);
+
+$loc = MTrackConfig::get('core', 'httpcachedir');
+if (!$loc) {
+  $loc = MTrackConfig::get('core', 'vardir') . '/httpcache';
+  if (!is_dir($loc)) {
+    mkdir($loc);
+  }
+}
+
+$cache_duration = 3600; // seconds
+
+$source = null;
+if (isset($data['avatar'])) {
+  $source = $data['avatar'];
+} else if (isset($data['email'])) {
+  $source = "http://www.gravatar.com/avatar/" .
+    md5(strtolower($data['email'])) . "?s=$size&d=wavatar";
+} else if (preg_match('/^https?:\/\//', $username)) {
+  // Let's try a favatar!
+
+  function extract_favatar_link($filename, $relurl)
+  {
+    $data = file_get_contents($filename);
+    // Just get the head
+    if (preg_match('@<head[^>]*>(.*)</head>@smi', $data, $M)) {
+      $data = "<html><head>" . $M[1] . "</head></html>";
+    }
+    $doc = new DomDocument;
+    if (!@$doc->loadHTML($data)) {
+      return null;
+    }
+    $xpath = new DomXPath($doc);
+    $links = $xpath->query(
+        '/html/head/link[@rel="shortcut icon" or @rel="icon"]');
+
+    if (substr($relurl, -1) != '/') {
+      $relurl .= '/';
+    }
+
+    foreach ($links as $link) {
+      $url = $link->getAttribute('href');
+      if ($url !== null) {
+        break;
+      }
+    }
+
+    if ($url === null) {
+      return $relurl . 'favicon.ico';
+    }
+
+    if (!preg_match('@^([a-zA-Z]+)://@', $url)) {
+      /* fixup relative links */
+      if ($url[0] == '/') {
+        $url = substr($url, 1);
+      }
+      foreach ($xpath->query('/html/head/base') as $base) {
+        $url = $base->getAttribute('href') . $url;
+      }
+      if (!preg_match('@^([a-zA-Z]+)://@', $url)) {
+        $url = $relurl . $url;
+      }
+    }
+    return $url;
+  }
+
+  list($head, $link) = cache_get_url_and_operate(
+    $username, 'extract_favatar_link', $username);
+
+  $source = $link;
+}
+
+function logit($msg)
+{
+#  echo "$msg<br>";
+//  error_log($msg);
+}
+
+/**
+ * Fetches the contents of the URL $source using a cache.
+ * Optionally runs a callback specified by $funcname on the
+ * data while it is under a lock (to ensure a consistent view).
+ * $funcname is passed the local cache filename as its first parameter.
+ * Any additional parameters passed to this function will be passed
+ * to $funcname as parameters after the cache filename.
+ *
+ * returns an array(
+ *  0 => data from the url
+ *  1 => return value of optional funcname
+ * )
+ */
+function cache_get_url_and_operate($source, $funcname = null /* args */)
+{
+  global $loc;
+  global $cache_duration;
+
+  $args = func_get_args();
+  if (count($args) > 2) {
+    array_shift($args);
+    array_shift($args);
+  } else {
+    $args = array();
+  }
+  $cache = $loc . "/" . md5($source);
+  array_unshift($args, $cache);
+
+  // cache file population, avoiding thundering herd and maintaining
+  // consistency under concurrency.
+
+  $dat = null;
+  $tosend = null;
+
+  $tries = 20;
+  while ($tries-- > 0) {
+    logit("tries=$tries");
+    // Can we open the file for read?
+    $fp = @fopen($cache, 'r+b');
+    if (!$fp) {
+      $fp = @fopen($cache, 'x+');
+    }
+    if ($fp) {
+      // Yes; get a lock for consistency
+      flock($fp, LOCK_SH);
+      logit("got shared lock");
+      // What do we need to do?
+      $st = fstat($fp);
+      if ($st['size'] == 0) {
+        // No data in the file, let's see if we can do something about that
+        logit("zero size; getting ex lock");
+        flock($fp, LOCK_EX);
+        $st = fstat($fp);
+        if ($st['size'] == 0) {
+          // We get to fix it
+          logit("zero sized; we're fixing it, reading from $source");
+          $tosend = file_get_contents($source);
+          fwrite($fp, $tosend);
+
+          if ($funcname !== null) {
+            $dat = call_user_func_array($funcname, $args);
+          }
+          break;
+        }
+        // Someone else fixed it
+        logit("Someone else fixed it, size is now $st[size]");
+      } else if (time() - $st['mtime'] > $cache_duration) {
+        // Someone needs to re-fetch the data
+        logit("Past cache period, getting ex lock");
+        flock($fp, LOCK_EX);
+        $st = fstat($fp);
+        if (time() - $st['mtime'] > $cache_duration) {
+          // We get to fix it
+          logit("cache expired; reading from $source, truncating");
+          ftruncate($fp, 0);
+          rewind($fp);
+          $tosend = file_get_contents($source);
+          logit("read " . strlen($tosend) . " from $source");
+          $x = fwrite($fp, $tosend);
+          logit("wrote $x to local cache file");
+          if ($funcname !== null) {
+            $dat = call_user_func_array($funcname, $args);
+          }
+          break;
+        }
+        // Someone else fixed it
+        logit("Someone fixed it, mtime now $st[mtime]");
+      }
+      // Good to read through
+      logit("Reading through cache");
+      $tosend = stream_get_contents($fp);
+      if ($funcname !== null) {
+        $dat = call_user_func_array($funcname, $args);
+      }
+      break;
+    }
+    logit("Couldn't get data, sleeping and retrying");
+    usleep(100);
+  }
+  if ($fp) {
+    flock($fp, LOCK_UN);
+    fclose($fp);
+  }
+  return array($tosend, $dat);
+}
+
+if ($source) {
+  $hint = basename($source);
+  list($tosend, $mime) = cache_get_url_and_operate(
+    $source, 'mtrack_mime_detect', $hint);
+  if ($mime) {
+    logit("All is good, sending data");
+  } else {
+    logit("Unable to get data");
+  }
+  if ($mime) {
+    header("Content-Type: $mime");
+    header("Content-Disposition: inline; filename=\"$hint\"");
+    echo $tosend;
+    exit;
+  }
+}
+
+$cache = dirname(__FILE__) . "/images/default_avatar.png";
+$mime = mtrack_mime_detect($cache, $cache);
+header("Content-Type: $mime");
+header("Content-Disposition: inline");
+readfile($cache);
+exit;
+
diff --git a/web/browse.php b/web/browse.php
new file mode 100644 (file)
index 0000000..0c64a8a
--- /dev/null
@@ -0,0 +1,518 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+$USE_AJAX = false;
+
+MTrackACL::requireAllRights('Browser', 'read');
+
+$pi = mtrack_get_pathinfo(true);
+$crumbs = MTrackSCM::makeBreadcrumbs($pi);
+if (!strlen($pi) || $pi == '/') {
+  $pi = '/';
+}
+if (count($crumbs) > 2) {
+  $repo = MTrackSCM::factory($pi);
+} else {
+  $repo = null;
+}
+
+if (!isset($_GET['_'])) {
+  $AJAX = false;
+} else {
+  $AJAX = true;
+}
+
+function one_line_cl($changelog)
+{
+  list($one) = explode("\n", $changelog);
+  return rtrim($one, " \r\n");
+}
+
+function get_browse_data($repo, $pi, $object, $ident)
+{
+  global $ABSWEB;
+
+  $data = new stdclass;
+  $data->dirs = array();
+  $data->files = array();
+  $data->jumps = array();
+
+  if (!$repo) {
+    return $data;
+  }
+  $branches = $repo->getBranches();
+  $tags = $repo->getTags();
+  if (count($branches) + count($tags)) {
+    $jumps = array("" => "- Select Branch / Tag - ");
+    if (is_array($branches)) {
+      foreach ($branches as $name => $notcare) {
+        $jumps["branch:$name"] = "Branch: $name";
+      }
+    }
+    if (is_array($tags)) {
+      foreach ($tags as $name => $notcare) {
+        $jumps["tag:$name"] = "Tag: $name";
+      }
+    }
+    $data->jumps = $jumps;
+  }
+  $files = array();
+  $dirs = array();
+
+  if ($repo) {
+    try {
+      $ents = $repo->readdir($pi, $object, $ident);
+    } catch (Exception $e) {
+      // Typically a freshly created repo
+      $ents = array();
+      $data->err = $e->getMessage();
+    }
+    foreach ($ents as $file) {
+      $basename = basename($file->name);
+      if ($file->is_dir) {
+        $dirs[$basename] = $file;
+      } else {
+        $files[$basename] = $file;
+      }
+    }
+  }
+  uksort($files, 'strnatcmp');
+  uksort($dirs, 'strnatcmp');
+
+  $data->files = array();
+  $data->dirs = array();
+
+  $urlbase = $ABSWEB . 'browse.php';
+  $pathbase = '/' . $repo->getBrowseRootName();
+  $urlbase .= $pathbase;
+
+  foreach ($dirs as $basename => $file) {
+    $ent = $file->getChangeEvent();
+    $url = $urlbase . '/' . $file->name;
+    $d = new stdclass;
+    $d->url = $url;
+    $d->basename = $basename;
+    $d->rev = $ent->rev;
+    $d->ctime = $ent->ctime;
+    $d->changeby = $ent->changeby;
+    $d->changelog = one_line_cl($ent->changelog);
+
+    $data->dirs[] = $d;
+  }
+  foreach ($files as $basename => $file) {
+    $ent = $file->getChangeEvent();
+    $url = $ABSWEB . 'file.php' . $pathbase .
+            '/' . $file->name . '?rev=' . $ent->rev;
+    $d = new stdclass;
+    $d->url = $url;
+    $d->basename = $basename;
+    $d->rev = $ent->rev;
+    $d->ctime = $ent->ctime;
+    $d->changeby = $ent->changeby;
+    $d->changelog = one_line_cl($ent->changelog);
+
+    $data->files[] = $d;
+  }
+
+  return $data;
+}
+
+if (isset($_GET['jump']) && strlen($_GET['jump'])) {
+  list($object, $ident) = explode(':', $_GET['jump'], 2);
+} else {
+  $object = null;
+  $ident = null;
+}
+
+if ($USE_AJAX && !$AJAX) {
+  mtrack_head("Browse $pi");
+
+  // Since big dirs can take a while to gather the browse data,
+  // We want to show *something* to the user while we wait for
+  // the data to come in
+  $g = $_GET;
+  $g['_'] = '_';
+  $url = $_SERVER['REQUEST_URI'] . '?' . http_build_query($g);
+  echo <<<HTML
+<div id='browsediv'>
+  <p>Loading browse data, please wait</p>
+</div>
+<script>
+\$(document).ready(function () {
+  \$('#browsediv').load('$url');
+});
+</script>
+HTML;
+  mtrack_foot();
+} else {
+  if (!$USE_AJAX) {
+    mtrack_head("Browse $pi");
+  }
+
+$bdata = mtrack_cache('get_browse_data',
+  array($repo, $pi, $object, $ident));
+
+if (isset($bdata->err) && strlen($pi) > 1) {
+  throw new Exception($bdata->err);
+}
+
+/* Render a bread-crumb enabled location indicator */
+echo "<div class='browselocation'>Location: ";
+$location = null;
+foreach ($crumbs as $path) {
+  if (!strlen($path)) {
+    $path = '[root]';
+  } else {
+    $location .= '/' . urlencode($path);
+  }
+  $path = htmlentities($path, ENT_QUOTES, 'utf-8');
+  echo "<a href='{$ABSWEB}browse.php$location'>$path</a> / ";
+}
+
+if (count($bdata->jumps)) {
+  echo "<form>";
+  echo mtrack_select_box("jump", $bdata->jumps,
+        isset($_GET['jump']) ? $_GET['jump'] : null);
+  echo "<button type='submit'>Choose</button></form>\n";
+}
+
+echo "</div>";
+
+$me = mtrack_canon_username(MTrackAuth::whoami());
+if (MTrackACL::hasAllRights('Browser', 'create')) {
+  /* some users may have rights to create repos that belong to projects.
+    * Determine that list of projects here, because we need it for both
+    * the fork and new repo cases */
+  $owners = array("user:$me" => $me);
+
+  foreach (MTrackDB::q(
+      'select projid, shortname, name from projects order by ordinal')
+      as $row)
+  {
+    if (MTrackACL::hasAllRights("project:$row[0]", 'modify')) {
+      $owners["project:$row[1]"] = $row[1];
+    }
+  }
+  if (count($owners) > 1) {
+    $owners = mtrack_select_box('repo:parent', $owners, null, true);
+  } else {
+    $owners = '';
+  }
+}
+
+if ($repo) {
+  MTrackACL::requireAllRights("repo:$repo->repoid", 'read');
+
+  $description = MTrackWiki::format_to_html($repo->description);
+  $url = $repo->getCheckoutCommand();
+
+  echo "<div class='repodesc'>$description</div>";
+  if (strlen($url)) {
+    echo "<div class='checkout'>\n";
+    echo "Use the following command to obtain a working copy:<br>";
+    echo "<pre>\$ $url</pre>";
+    echo "</div>\n";
+  }
+
+
+  if ($repo->canFork() && MTrackACL::hasAllRights('Browser', 'fork')
+      && MTrackConfig::get('repos', 'allow_user_repo_creation')) {
+    $forkname = "$me/$repo->shortname";
+    if ($forkname == $repo->getBrowseRootName()) {
+      /* if this is mine already, make a "more unique" name for my new fork */
+      $forkname = $repo->shortname . '2';
+    } else {
+      $forkname = $repo->shortname;
+    }
+    $forkname = htmlentities($forkname, ENT_QUOTES, 'utf-8');
+    echo <<<FORK
+<div id='forkdialog' style='display:none'
+  title='Really create a fork?'>
+<form id='forkform' action='${ABSWEB}admin/forkrepo.php' method='post'>
+  <input type='hidden' name='source' value='$repo->repoid'>
+  <p>
+    A fork is your own copy of a repo that is stored and maintained
+    on the server.
+  </p>
+  <p>
+    If all you want to do is obtain a working copy so that you can
+    collaborate on this repo, you should not create a fork.
+  </p>
+  <p>
+    You may want to fork if you want the server to keep your work backed up,
+    or to collaborate with others on work that you want to share
+    with this repo later on.
+  </p>
+  <p>
+    Choose a name for your fork:
+    $owners <input type='text' name='name' value='$forkname'>
+  </p>
+</form>
+</div>
+<button id='forkbtn' type='button'>Fork</button>
+<script>
+\$(document).ready(function() {
+  \$('#forkdialog').dialog({
+    autoOpen: false,
+    bgiframe: true,
+    resizable: false,
+    width: 600,
+    modal: true,
+    buttons: {
+      'No': function() {
+        $(this).dialog('close');
+      },
+      'Fork': function() {
+        $('#forkform').submit();
+      }
+    }
+  });
+  \$('#forkbtn').click(function () {
+    \$('#forkdialog').dialog('open');
+    return false;
+  });
+});
+</script>
+FORK
+    ;
+  }
+  $mine = "user:$me";
+  if ($repo->parent &&
+      MTrackACL::hasAllRights("repo:$repo->repoid", "delete")) {
+    echo <<<FORK
+      <div id='deletedialog' style='display:none'
+      title='Really delete this repo?'>
+      <form id='deleteform' action='${ABSWEB}admin/deleterepo.php'
+        method='post'>
+      <input type='hidden' name='repoid' value='$repo->repoid'>
+      <p>Are you sure you want to delete this repo?</p>
+      <p><b>You cannot undo this action; any data will be permanently
+        deleted</b></p>
+      </form>
+      </div>
+      <button id='deletebtn' type='button'>Delete</button>
+<script>
+\$(document).ready(function() {
+  \$('#deletedialog').dialog({
+    autoOpen: false,
+    bgiframe: true,
+    resizable: false,
+    modal: true,
+    buttons: {
+      'No': function() {
+        $(this).dialog('close');
+      },
+      'Delete': function() {
+        $('#deleteform').submit();
+      }
+    }
+  });
+  \$('#deletebtn').click(function () {
+    \$('#deletedialog').dialog('open');
+    return false;
+  });
+});
+</script>
+FORK
+;
+  }
+  if (MTrackACL::hasAllRights("repo:$repo->repoid", "modify")) {
+    echo <<<EDIT
+<a class='button' href='{$ABSWEB}admin/repo.php/$repo->repoid'>Edit</a>
+EDIT
+    ;
+  }
+  MTrackWatch::renderWatchUI('repo', $repo->repoid);
+
+  echo "<br>\n<a href='{$ABSWEB}log.php/{$repo->getBrowseRootName()}/$pi'>Show History</a><br>\n";
+}
+
+if (!$repo && MTrackACL::hasAllRights('Browser', 'fork')
+    && MTrackConfig::get('repos', 'allow_user_repo_creation')) {
+$repotypes = array();
+foreach (MTrackRepo::getAvailableSCMs() as $t => $r) {
+  $d = $r->getSCMMetaData();
+  $repotypes[$t] = $d['name'];
+}
+$repotypes = mtrack_select_box("repo:type", $repotypes, null, true);
+echo <<<NEWREPO
+<div id='newdialog' style='display:none'
+  title='Create a new repo?'>
+<form id='newrepoform' action='${ABSWEB}admin/repo.php/new' method='post'>
+<p>
+  Choose a name for your repo:
+  $owners <input type='text' name='repo:name' value='myrepo'>
+</p>
+<p>
+  Choose a repository type: $repotypes
+</p>
+<p>
+  Description:<br>
+  <em>You may use <a href='{$ABSWEB}help.php/WikiFormatting' target='_blank'>WikiFormatting</a></em><br>
+  <textarea name='repo:description' class='wiki shortwiki' rows='5' cols='78'></textarea>
+</form>
+</div>
+<button id='newrepobtn' type='button'>New</button>
+<script>
+\$(document).ready(function() {
+  \$('#newdialog').dialog({
+    autoOpen: false,
+    bgiframe: true,
+    resizable: false,
+    width: 600,
+    modal: true,
+    buttons: {
+      'Cancel': function() {
+        $(this).dialog('close');
+      },
+      'Create': function() {
+        $('#newrepoform').submit();
+      }
+    }
+  });
+  \$('#newrepobtn').click(function () {
+    \$('#newdialog').dialog('open');
+    return false;
+  });
+});
+</script>
+NEWREPO
+;
+}
+
+echo "<br>\n";
+
+?>
+<table class='listing' id='dirlist'>
+  <thead>
+    <tr>
+<?php
+if (!$repo) {
+?>
+      <th class='name' width='1%'>Name</th>
+      <th class='desc'>Description</th>
+<?php
+} else {
+?>
+      <th class='name' width='1%'>Name</th>
+      <th class='rev' width='1%'>Revision</th>
+      <th class='age' width='1%'>Age</th>
+      <th class='change'>Last Change</th>
+<?php
+}
+?>
+    </tr>
+  </thead>
+  <tbody>
+<?php
+$even = 1;
+
+if (count($crumbs) > 1) {
+  $class = $even++ % 2 ? 'even' : 'odd';
+  $url = $ABSWEB . 'browse.php' . dirname(mtrack_get_pathinfo(true));
+  if (isset($_GET['jump'])) {
+    $url .= '?jump=' . urlencode($_GET['jump']);
+  }
+  $url = htmlentities($url, ENT_QUOTES, 'utf-8');
+
+  echo "<tr class='$class'>\n";
+  echo "<td class='name'><a class='parent' href='$url'>.. [up]</a></td>";
+  if ($repo) {
+    echo "<td class='rev'></td>\n";
+    echo "<td class='age'></td>\n";
+    echo "<td class='change'></td>\n";
+  } else {
+    echo "<td class='desc'></td>\n";
+  }
+  echo "</tr>\n";
+}
+
+foreach ($bdata->dirs as $d) {
+  $class = $even++ % 2 ? 'even' : 'odd';
+  $url = $d->url;
+  if (isset($_GET['jump'])) {
+    $url .= '?jump=' . urlencode($_GET['jump']);
+  }
+  $url = htmlentities($url, ENT_QUOTES, 'utf-8');
+  echo "<tr class='$class'>\n";
+  echo "<td class='name'><a class='dir' href='$url'>$d->basename</a></td>";
+  echo "<td class='rev'>" . mtrack_changeset($d->rev, $repo) . "</td>\n";
+  echo "<td class='age'>" . mtrack_date($d->ctime) . "</td>\n";
+  echo "<td class='change'>" .
+    mtrack_username($d->changeby, array('size' => 16)) . ": " .
+    MTrackWiki::format_to_oneliner($d->changelog) . "</td>\n";
+  echo "</tr>\n";
+}
+
+foreach ($bdata->files as $d) {
+  $class = $even++ % 2 ? 'even' : 'odd';
+  $url = $d->url;
+  if (isset($_GET['jump'])) {
+    $url .= '&jump=' . urlencode($_GET['jump']);
+  }
+  $url = htmlentities($url, ENT_QUOTES, 'utf-8');
+  echo "<tr class='$class'>\n";
+  echo "<td class='name'><a class='file' href='$url'>$d->basename</a></td>";
+  echo "<td class='rev'>" . mtrack_changeset($d->rev, $repo) . "</td>\n";
+  echo "<td class='age'>" . mtrack_date($d->ctime) . "</td>\n";
+  echo "<td class='change'>" .
+    mtrack_username($d->changeby, array('size' => 16)) . ": " .
+    MTrackWiki::format_to_oneliner($d->changelog) . "</td>\n";
+  echo "</tr>\n";
+}
+
+if (!$repo) {
+  $mine = 'user:' . mtrack_canon_username(MTrackAuth::whoami());
+  $params = array();
+  if (count($crumbs) == 2 && $crumbs[1] != 'default') {
+    /* looking for a particular subset */
+    $where = "parent like('%:' || ?)";
+    $params[] = $crumbs[1];
+  } else if (count($crumbs) == 2 && $crumbs[1] == 'default') {
+    /* looking at system items */
+    $where = "parent = ''";
+  } else {
+    /* looking for top level items */
+    $where = "1 = 1";
+  }
+  /* have my own repos bubble up */
+  $params[] = $mine;
+  $sql = <<<SQL
+select repoid, parent, shortname, description
+from repos
+where $where
+order by
+  case when parent = ? then 0 else 1 end,
+  shortname
+SQL
+  ;
+  $q = MTrackDB::get()->prepare($sql);
+  $q->execute($params);
+
+  foreach ($q->fetchAll(PDO::FETCH_OBJ) as $rep) {
+    if (!MTrackACL::hasAnyRights("repo:$rep->repoid", 'read')) {
+      continue;
+    }
+
+    $class = $even++ % 2 ? 'even' : 'odd';
+    $url = $ABSWEB . 'browse.php/';
+    $label = MTrackRepo::makeDisplayName($rep);
+
+    $url .= $label;
+    echo "<tr class='$class'>\n";
+    echo "<td class='name'><a class='dir' href='$url'>$label</a></td>\n";
+    $desc = MTrackWiki::format_to_html($rep->description);
+    echo "<td class='desc'>$desc</td>\n";
+    echo "</tr>\n";
+  }
+}
+
+echo "</tbody></table>\n";
+
+  if (!$USE_AJAX) {
+    mtrack_foot();
+  }
+
+}
diff --git a/web/changeset.php b/web/changeset.php
new file mode 100644 (file)
index 0000000..2d1c8de
--- /dev/null
@@ -0,0 +1,152 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+MTrackACL::requireAllRights('Browser', 'read');
+
+$path = mtrack_get_pathinfo(true);
+$pi = $path;
+$repo = MTrackSCM::factory($pi);
+
+MTrackACL::requireAllRights("repo:$repo->repoid", 'read');
+
+function get_change_data($pi)
+{
+  $repo = MTrackSCM::factory($pi);
+  $ents = $repo->history(null, 1, 'rev', $pi);
+  $data = new stdclass;
+  if (!count($ents)) {
+    $data->ent = null;
+  } else {
+    $ent = $ents[0];
+    $data->ent = $ent;
+
+    // Determine project from the file list
+    $the_proj = $repo->projectFromPath($ent->files);
+    if ($the_proj > 1) {
+      $proj = MTrackProject::loadById($the_proj);
+      $changelog = $proj->adjust_links($ent->changelog, true);
+    } else {
+      $changelog = $ent->changelog;
+    }
+    $data->changelog = $changelog;
+
+    if (is_array($ent->files)) foreach ($ent->files as $file) {
+      $file->diff = mtrack_diff($repo->diff($file, $ent->rev));
+    }
+  }
+
+  return $data;
+}
+
+function get_change_data_relatives($pi, $rev)
+{
+  $repo = MTrackSCM::factory($pi);
+  $data = new stdclass;
+  list($data->parents, $data->kids) = $repo->getRelatedChanges($rev);
+  return $data;
+}
+
+$data = mtrack_cache('get_change_data', array($path), 864000);
+$ent = $data->ent;
+if ($ent === null) {
+  throw new Exception("invalid parameters");
+}
+
+$rdata = mtrack_cache('get_change_data_relatives', array($path, $ent->rev));
+
+if (isset($_GET['fmt']) && $_GET['fmt'] == 'diff') {
+  $filename = "$repo->shortname.$ent->rev.diff";
+  header("Content-Type: text/plain; name=\"$filename\"");
+  header("Content-Disposition: attachment; filename=\"$filename\"");
+
+  echo "Changeset: $repo->shortname $ent->rev\n";
+  echo "By: $ent->changeby\n";
+  echo "When: $ent->ctime\n";
+  echo "\n";
+  echo $data->changelog . "\n\n";
+
+  if (is_array($ent->files) && count($ent->files)) {
+    foreach ($ent->files as $id => $file) {
+      echo "$file->status $file->name\n";
+    }
+    echo "\n";
+
+    foreach ($ent->files as $id => $file) {
+      $fpath = $file->name;
+      if ($fpath[0] != '/') $fpath = '/' . $fpath;
+      $diff = $repo->diff($file, $ent->rev);
+      if (is_resource($diff)) {
+        echo stream_get_contents($diff);
+      } elseif (is_array($diff)) {
+        echo join("\n", $diff);
+      } else {
+        echo $diff;
+      }
+    }
+  }
+  exit;
+}
+
+mtrack_head("Changeset " . $ent->rev);
+
+echo "<div class='revinfo'>\n";
+echo "Revision: $repo->shortname $ent->rev";
+foreach ($ent->branches as $b) {
+  echo " " . mtrack_branch($b);
+}
+foreach ($ent->tags as $t) {
+  echo " " . mtrack_tag($t);
+}
+echo "<br>\n";
+
+
+echo MTrackWiki::format_to_html($data->changelog);
+
+echo "<div class='changeinfo'>\n";
+echo mtrack_username($ent->changeby, array('size' => 32)) . "<br>\n";
+echo mtrack_date($ent->ctime, true) . "<br>\n";
+
+if (count($rdata->parents)) {
+  echo "Prior:";
+  foreach ($rdata->parents as $p) {
+    echo " " . mtrack_changeset($p, $repo);
+  }
+  echo " ";
+}
+
+if (count($rdata->kids)) {
+  echo "Next:";
+  foreach ($rdata->kids as $kid) {
+    echo " " . mtrack_changeset($kid, $repo);
+  }
+}
+
+echo "</div>\n";
+echo "</div>\n";
+
+if (is_array($ent->files) && count($ent->files)) {
+  echo "<br><br><a href='${ABSWEB}changeset.php$path?fmt=diff'>Download diff</a>";
+  echo "<div class='difffiles'>Affected files:<ul>";
+  foreach ($ent->files as $id => $file) {
+    echo "<li><a href='#d$id'><b>$file->status</b> $file->name</a></li>\n";
+  }
+  echo "</ul></div>";
+
+  foreach ($ent->files as $id => $file) {
+    $fpath = $file->name;
+    if ($fpath[0] != '/') $fpath = '/' . $fpath;
+    echo "<a name='d$id'></a><a href='{$ABSWEB}file.php/{$repo->getBrowseRootName()}$fpath?rev=$ent->rev'>$file</a><br>\n";
+    $diff = $file->diff; // populated in get_change_data
+    if ($diff === null) {
+      echo "No diff available.  File status is <b>$file->status</b><br><br>";
+    } else {
+      echo $diff;
+    }
+  }
+}
+
+
+mtrack_foot();
+
diff --git a/web/css.php b/web/css.php
new file mode 100644 (file)
index 0000000..d573364
--- /dev/null
@@ -0,0 +1,29 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+
+$age = 3600;
+
+header('Content-Type: text/css');
+header("Cache-Control: public, max-age=$age, pre-check=$age");
+header('Expires: ' . date(DATE_COOKIE, time() + $age));
+
+$scripts = array(
+  'css/smoothness/jquery-ui-1.7.2.custom.css',
+  'mtrack.css',
+  'css/markitup/markitup-simple.css',
+  'css/markitup/wiki.css',
+  '../inc/hyperlight/vibrant-ink.css',
+  '../inc/hyperlight/zenburn.css',
+  '../inc/hyperlight/wezterm.css',
+);
+
+foreach ($scripts as $name) {
+  echo "\n/* $name */\n";
+  $dir = dirname($name);
+  $data = file_get_contents($name);
+  $data = preg_replace('@url\(([^)]+)\)@', "url($dir/\\1)", $data);
+  echo "$data\n";
+}
+
+
diff --git a/web/css/markitup/bold.png b/web/css/markitup/bold.png
new file mode 100755 (executable)
index 0000000..889ae80
Binary files /dev/null and b/web/css/markitup/bold.png differ
diff --git a/web/css/markitup/code.png b/web/css/markitup/code.png
new file mode 100755 (executable)
index 0000000..63fe6ce
Binary files /dev/null and b/web/css/markitup/code.png differ
diff --git a/web/css/markitup/h1.png b/web/css/markitup/h1.png
new file mode 100755 (executable)
index 0000000..9c122e9
Binary files /dev/null and b/web/css/markitup/h1.png differ
diff --git a/web/css/markitup/h2.png b/web/css/markitup/h2.png
new file mode 100755 (executable)
index 0000000..fbd8765
Binary files /dev/null and b/web/css/markitup/h2.png differ
diff --git a/web/css/markitup/h3.png b/web/css/markitup/h3.png
new file mode 100755 (executable)
index 0000000..c7836cf
Binary files /dev/null and b/web/css/markitup/h3.png differ
diff --git a/web/css/markitup/h4.png b/web/css/markitup/h4.png
new file mode 100755 (executable)
index 0000000..4e929ea
Binary files /dev/null and b/web/css/markitup/h4.png differ
diff --git a/web/css/markitup/h5.png b/web/css/markitup/h5.png
new file mode 100755 (executable)
index 0000000..30cabeb
Binary files /dev/null and b/web/css/markitup/h5.png differ
diff --git a/web/css/markitup/h6.png b/web/css/markitup/h6.png
new file mode 100755 (executable)
index 0000000..058170a
Binary files /dev/null and b/web/css/markitup/h6.png differ
diff --git a/web/css/markitup/handle.png b/web/css/markitup/handle.png
new file mode 100755 (executable)
index 0000000..3993b20
Binary files /dev/null and b/web/css/markitup/handle.png differ
diff --git a/web/css/markitup/italic.png b/web/css/markitup/italic.png
new file mode 100755 (executable)
index 0000000..8482ac8
Binary files /dev/null and b/web/css/markitup/italic.png differ
diff --git a/web/css/markitup/link.png b/web/css/markitup/link.png
new file mode 100755 (executable)
index 0000000..25eacb7
Binary files /dev/null and b/web/css/markitup/link.png differ
diff --git a/web/css/markitup/list-bullet.png b/web/css/markitup/list-bullet.png
new file mode 100755 (executable)
index 0000000..4a8672b
Binary files /dev/null and b/web/css/markitup/list-bullet.png differ
diff --git a/web/css/markitup/list-numeric.png b/web/css/markitup/list-numeric.png
new file mode 100755 (executable)
index 0000000..33b0b8d
Binary files /dev/null and b/web/css/markitup/list-numeric.png differ
diff --git a/web/css/markitup/markitup-simple.css b/web/css/markitup/markitup-simple.css
new file mode 100755 (executable)
index 0000000..85904d3
--- /dev/null
@@ -0,0 +1,128 @@
+/* -------------------------------------------------------------------
+// markItUp! Universal MarkUp Engine, JQuery plugin
+// By Jay Salvat - http://markitup.jaysalvat.com/
+// ------------------------------------------------------------------*/
+.markItUp * {
+       margin:0px; padding:0px;
+       outline:none;
+}
+.markItUp a {
+       border: none;
+}
+.markItUp a:link,
+.markItUp a:visited {
+       color:#000;
+       text-decoration:none;
+}
+.markItUp  {
+       /*
+       width:700px;
+       */
+       margin:5px 0 5px 0;
+}
+.markItUpContainer  {
+       /*
+       font:11px Verdana, Arial, Helvetica, sans-serif;
+       */
+}
+.markItUpEditor {
+       /*
+       font:12px 'Courier New', Courier, monospace;
+       padding:5px;
+       line-height:18px;
+       */
+       width: 100%;
+       height:320px;
+       clear: both;
+       display: block;
+       overflow: auto;
+}
+.markItUpPreviewFrame  {
+       overflow:auto;
+       background-color:#FFF;
+       width:99.9%;
+       height:300px;
+       margin:5px 0;
+}
+.markItUpFooter {
+       width:100%;
+}
+.markItUpResizeHandle {
+       overflow:hidden;
+       width:22px; height:5px;
+       margin-left:auto;
+       margin-right:auto;
+       background-image:url(handle.png);
+       cursor:n-resize;
+}
+/***************************************************************************************/
+/* first row of buttons */
+.markItUpHeader ul li  {
+       list-style:none;
+       float:left;
+       position:relative;
+}
+.markItUpHeader ul li:hover > ul{
+       display:block;
+}
+.markItUpHeader ul .markItUpDropMenu {
+       background:transparent url(menu.png) no-repeat 115% 50%;
+       margin-right:5px;
+}
+.markItUpHeader ul .markItUpDropMenu li {
+       margin-right:0px;
+}
+/* next rows of buttons */
+.markItUpHeader ul ul {
+       display:none;
+       position:absolute;
+       top:18px; left:0px;     
+       background:#FFF;
+       border:1px solid #000;
+}
+.markItUpHeader ul ul li {
+       float:none;
+       border-bottom:1px solid #000;
+}
+.markItUpHeader ul ul .markItUpDropMenu {
+       background:#FFF url(submenu.png) no-repeat 100% 50%;
+}
+.markItUpHeader ul .markItUpSeparator {
+       margin:0 10px;
+       width:1px;
+       height:16px;
+       overflow:hidden;
+       background-color:#CCC;
+}
+.markItUpHeader ul ul .markItUpSeparator {
+       width:auto; height:1px;
+       margin:0px;
+}
+/* next rows of buttons */
+.markItUpHeader ul ul ul {
+       position:absolute;
+       top:-1px; left:150px; 
+}
+.markItUpHeader ul ul ul li {
+       float:none;
+}
+.markItUpHeader ul a {
+       display:block;
+       width:16px; height:16px;
+       text-indent:-10000px;
+       background-repeat:no-repeat;
+       padding:3px;
+       margin:0px;
+}
+.markItUpHeader ul ul a {
+       display:block;
+       padding-left:0px;
+       text-indent:0;
+       width:120px; 
+       padding:5px 5px 5px 25px;
+       background-position:2px 50%;
+}
+.markItUpHeader ul ul a:hover  {
+       color:#FFF;
+       background-color:#000;
+}
diff --git a/web/css/markitup/menu.png b/web/css/markitup/menu.png
new file mode 100755 (executable)
index 0000000..44a07af
Binary files /dev/null and b/web/css/markitup/menu.png differ
diff --git a/web/css/markitup/picture.png b/web/css/markitup/picture.png
new file mode 100755 (executable)
index 0000000..4a158fe
Binary files /dev/null and b/web/css/markitup/picture.png differ
diff --git a/web/css/markitup/preview.png b/web/css/markitup/preview.png
new file mode 100755 (executable)
index 0000000..a9925a0
Binary files /dev/null and b/web/css/markitup/preview.png differ
diff --git a/web/css/markitup/quotes.png b/web/css/markitup/quotes.png
new file mode 100755 (executable)
index 0000000..e54ebeb
Binary files /dev/null and b/web/css/markitup/quotes.png differ
diff --git a/web/css/markitup/stroke.png b/web/css/markitup/stroke.png
new file mode 100755 (executable)
index 0000000..612058a
Binary files /dev/null and b/web/css/markitup/stroke.png differ
diff --git a/web/css/markitup/submenu.png b/web/css/markitup/submenu.png
new file mode 100755 (executable)
index 0000000..03d1977
Binary files /dev/null and b/web/css/markitup/submenu.png differ
diff --git a/web/css/markitup/url.png b/web/css/markitup/url.png
new file mode 100755 (executable)
index 0000000..b8edc12
Binary files /dev/null and b/web/css/markitup/url.png differ
diff --git a/web/css/markitup/wiki.css b/web/css/markitup/wiki.css
new file mode 100644 (file)
index 0000000..40b0142
--- /dev/null
@@ -0,0 +1,61 @@
+/* -------------------------------------------------------------------
+// markItUp!
+// By Jay Salvat - http://markitup.jaysalvat.com/
+// ------------------------------------------------------------------*/
+
+.markItUpButton1 a {
+       background-image:url(h1.png); 
+}
+.markItUpButton2 a {
+       background-image:url(h2.png); 
+}
+.markItUpButton3 a {
+       background-image:url(h3.png); 
+}
+.markItUpButton4 a {
+       background-image:url(h4.png); 
+}
+.markItUpButton5 a {
+       background-image:url(h5.png); 
+}
+
+.markItUpButton6 a {
+       background-image:url(bold.png);
+}
+.markItUpButton7 a {
+       background-image:url(italic.png);
+}
+.markItUpButton8 a {
+       background-image:url(stroke.png);
+}
+
+.markItUpButton9 a {
+       background-image:url(list-bullet.png);
+}
+.markItUpButton10 a {
+       background-image:url(list-numeric.png);
+}
+
+/*
+.markItUpButton11 a {
+       background-image:url(picture.png); 
+}
+.markItUpButton12 a {
+       background-image:url(link.png);
+}
+.markItUpButton13 a {
+       background-image:url(url.png);
+}
+*/
+
+.markItUpButton11 a    {
+       background-image:url(quotes.png);
+}
+.markItUpButton12 a    {
+       background-image:url(code.png);
+}
+
+.preview a {
+       background-image:url(preview.png);
+}
+
diff --git a/web/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png b/web/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png
new file mode 100755 (executable)
index 0000000..5b5dab2
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png differ
diff --git a/web/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png b/web/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png
new file mode 100755 (executable)
index 0000000..ac8b229
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png differ
diff --git a/web/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png b/web/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png
new file mode 100755 (executable)
index 0000000..ad3d634
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png differ
diff --git a/web/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png b/web/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png
new file mode 100755 (executable)
index 0000000..42ccba2
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png differ
diff --git a/web/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png b/web/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png
new file mode 100755 (executable)
index 0000000..5a46b47
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png differ
diff --git a/web/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png b/web/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png
new file mode 100755 (executable)
index 0000000..86c2baa
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png differ
diff --git a/web/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png b/web/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png
new file mode 100755 (executable)
index 0000000..4443fdc
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png differ
diff --git a/web/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png b/web/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png
new file mode 100755 (executable)
index 0000000..7c9fa6c
Binary files /dev/null and b/web/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png differ
diff --git a/web/css/smoothness/images/ui-icons_222222_256x240.png b/web/css/smoothness/images/ui-icons_222222_256x240.png
new file mode 100755 (executable)
index 0000000..ee039dc
Binary files /dev/null and b/web/css/smoothness/images/ui-icons_222222_256x240.png differ
diff --git a/web/css/smoothness/images/ui-icons_2e83ff_256x240.png b/web/css/smoothness/images/ui-icons_2e83ff_256x240.png
new file mode 100755 (executable)
index 0000000..45e8928
Binary files /dev/null and b/web/css/smoothness/images/ui-icons_2e83ff_256x240.png differ
diff --git a/web/css/smoothness/images/ui-icons_454545_256x240.png b/web/css/smoothness/images/ui-icons_454545_256x240.png
new file mode 100755 (executable)
index 0000000..7ec70d1
Binary files /dev/null and b/web/css/smoothness/images/ui-icons_454545_256x240.png differ
diff --git a/web/css/smoothness/images/ui-icons_888888_256x240.png b/web/css/smoothness/images/ui-icons_888888_256x240.png
new file mode 100755 (executable)
index 0000000..5ba708c
Binary files /dev/null and b/web/css/smoothness/images/ui-icons_888888_256x240.png differ
diff --git a/web/css/smoothness/images/ui-icons_cd0a0a_256x240.png b/web/css/smoothness/images/ui-icons_cd0a0a_256x240.png
new file mode 100755 (executable)
index 0000000..7930a55
Binary files /dev/null and b/web/css/smoothness/images/ui-icons_cd0a0a_256x240.png differ
diff --git a/web/css/smoothness/jquery-ui-1.7.2.custom.css b/web/css/smoothness/jquery-ui-1.7.2.custom.css
new file mode 100755 (executable)
index 0000000..444486b
--- /dev/null
@@ -0,0 +1,406 @@
+/*
+* jQuery UI CSS Framework
+* Copyright (c) 2009 AUTHORS.txt (http://jqueryui.com/about)
+* Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses.
+*/
+
+/* Layout helpers
+----------------------------------*/
+.ui-helper-hidden { display: none; }
+.ui-helper-hidden-accessible { position: absolute; left: -99999999px; }
+.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; }
+.ui-helper-clearfix:after { content: "."; display: block; height: 0; clear: both; visibility: hidden; }
+.ui-helper-clearfix { display: inline-block; }
+/* required comment for clearfix to work in Opera \*/
+* html .ui-helper-clearfix { height:1%; }
+.ui-helper-clearfix { display:block; }
+/* end clearfix */
+.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); }
+
+
+/* Interaction Cues
+----------------------------------*/
+.ui-state-disabled { cursor: default !important; }
+
+
+/* Icons
+----------------------------------*/
+
+/* states and images */
+.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; }
+
+
+/* Misc visuals
+----------------------------------*/
+
+/* Overlays */
+.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
+
+
+
+/*
+* jQuery UI CSS Framework
+* Copyright (c) 2009 AUTHORS.txt (http://jqueryui.com/about)
+* Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses.
+* To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Verdana,Arial,sans-serif&fwDefault=normal&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=cccccc&bgTextureHeader=03_highlight_soft.png&bgImgOpacityHeader=75&borderColorHeader=aaaaaa&fcHeader=222222&iconColorHeader=222222&bgColorContent=ffffff&bgTextureContent=01_flat.png&bgImgOpacityContent=75&borderColorContent=aaaaaa&fcContent=222222&iconColorContent=222222&bgColorDefault=e6e6e6&bgTextureDefault=02_glass.png&bgImgOpacityDefault=75&borderColorDefault=d3d3d3&fcDefault=555555&iconColorDefault=888888&bgColorHover=dadada&bgTextureHover=02_glass.png&bgImgOpacityHover=75&borderColorHover=999999&fcHover=212121&iconColorHover=454545&bgColorActive=ffffff&bgTextureActive=02_glass.png&bgImgOpacityActive=65&borderColorActive=aaaaaa&fcActive=212121&iconColorActive=454545&bgColorHighlight=fbf9ee&bgTextureHighlight=02_glass.png&bgImgOpacityHighlight=55&borderColorHighlight=fcefa1&fcHighlight=363636&iconColorHighlight=2e83ff&bgColorError=fef1ec&bgTextureError=02_glass.png&bgImgOpacityError=95&borderColorError=cd0a0a&fcError=cd0a0a&iconColorError=cd0a0a&bgColorOverlay=aaaaaa&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=aaaaaa&bgTextureShadow=01_flat.png&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px
+*/
+
+
+/* Component containers
+----------------------------------*/
+.ui-widget { font-family: Verdana,Arial,sans-serif; font-size: 1.1em; }
+.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif; font-size: 1em; }
+.ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_flat_75_ffffff_40x100.png) 50% 50% repeat-x; color: #222222; }
+.ui-widget-content a { color: #222222; }
+.ui-widget-header { border: 1px solid #aaaaaa; background: #cccccc url(images/ui-bg_highlight-soft_75_cccccc_1x100.png) 50% 50% repeat-x; color: #222222; font-weight: bold; }
+.ui-widget-header a { color: #222222; }
+
+/* Interaction states
+----------------------------------*/
+.ui-state-default, .ui-widget-content .ui-state-default { border: 1px solid #d3d3d3; background: #e6e6e6 url(images/ui-bg_glass_75_e6e6e6_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #555555; outline: none; }
+.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555555; text-decoration: none; outline: none; }
+.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus { border: 1px solid #999999; background: #dadada url(images/ui-bg_glass_75_dadada_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; outline: none; }
+.ui-state-hover a, .ui-state-hover a:hover { color: #212121; text-decoration: none; outline: none; }
+.ui-state-active, .ui-widget-content .ui-state-active { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_glass_65_ffffff_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; outline: none; }
+.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #212121; outline: none; text-decoration: none; }
+
+/* Interaction Cues
+----------------------------------*/
+.ui-state-highlight, .ui-widget-content .ui-state-highlight {border: 1px solid #fcefa1; background: #fbf9ee url(images/ui-bg_glass_55_fbf9ee_1x400.png) 50% 50% repeat-x; color: #363636; }
+.ui-state-highlight a, .ui-widget-content .ui-state-highlight a { color: #363636; }
+.ui-state-error, .ui-widget-content .ui-state-error {border: 1px solid #cd0a0a; background: #fef1ec url(images/ui-bg_glass_95_fef1ec_1x400.png) 50% 50% repeat-x; color: #cd0a0a; }
+.ui-state-error a, .ui-widget-content .ui-state-error a { color: #cd0a0a; }
+.ui-state-error-text, .ui-widget-content .ui-state-error-text { color: #cd0a0a; }
+.ui-state-disabled, .ui-widget-content .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; }
+.ui-priority-primary, .ui-widget-content .ui-priority-primary { font-weight: bold; }
+.ui-priority-secondary, .ui-widget-content .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; }
+
+/* Icons
+----------------------------------*/
+
+/* states and images */
+.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); }
+.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }
+.ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }
+.ui-state-default .ui-icon { background-image: url(images/ui-icons_888888_256x240.png); }
+.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }
+.ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }
+.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png); }
+.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png); }
+
+/* positioning */
+.ui-icon-carat-1-n { background-position: 0 0; }
+.ui-icon-carat-1-ne { background-position: -16px 0; }
+.ui-icon-carat-1-e { background-position: -32px 0; }
+.ui-icon-carat-1-se { background-position: -48px 0; }
+.ui-icon-carat-1-s { background-position: -64px 0; }
+.ui-icon-carat-1-sw { background-position: -80px 0; }
+.ui-icon-carat-1-w { background-position: -96px 0; }
+.ui-icon-carat-1-nw { background-position: -112px 0; }
+.ui-icon-carat-2-n-s { background-position: -128px 0; }
+.ui-icon-carat-2-e-w { background-position: -144px 0; }
+.ui-icon-triangle-1-n { background-position: 0 -16px; }
+.ui-icon-triangle-1-ne { background-position: -16px -16px; }
+.ui-icon-triangle-1-e { background-position: -32px -16px; }
+.ui-icon-triangle-1-se { background-position: -48px -16px; }
+.ui-icon-triangle-1-s { background-position: -64px -16px; }
+.ui-icon-triangle-1-sw { background-position: -80px -16px; }
+.ui-icon-triangle-1-w { background-position: -96px -16px; }
+.ui-icon-triangle-1-nw { background-position: -112px -16px; }
+.ui-icon-triangle-2-n-s { background-position: -128px -16px; }
+.ui-icon-triangle-2-e-w { background-position: -144px -16px; }
+.ui-icon-arrow-1-n { background-position: 0 -32px; }
+.ui-icon-arrow-1-ne { background-position: -16px -32px; }
+.ui-icon-arrow-1-e { background-position: -32px -32px; }
+.ui-icon-arrow-1-se { background-position: -48px -32px; }
+.ui-icon-arrow-1-s { background-position: -64px -32px; }
+.ui-icon-arrow-1-sw { background-position: -80px -32px; }
+.ui-icon-arrow-1-w { background-position: -96px -32px; }
+.ui-icon-arrow-1-nw { background-position: -112px -32px; }
+.ui-icon-arrow-2-n-s { background-position: -128px -32px; }
+.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; }
+.ui-icon-arrow-2-e-w { background-position: -160px -32px; }
+.ui-icon-arrow-2-se-nw { background-position: -176px -32px; }
+.ui-icon-arrowstop-1-n { background-position: -192px -32px; }
+.ui-icon-arrowstop-1-e { background-position: -208px -32px; }
+.ui-icon-arrowstop-1-s { background-position: -224px -32px; }
+.ui-icon-arrowstop-1-w { background-position: -240px -32px; }
+.ui-icon-arrowthick-1-n { background-position: 0 -48px; }
+.ui-icon-arrowthick-1-ne { background-position: -16px -48px; }
+.ui-icon-arrowthick-1-e { background-position: -32px -48px; }
+.ui-icon-arrowthick-1-se { background-position: -48px -48px; }
+.ui-icon-arrowthick-1-s { background-position: -64px -48px; }
+.ui-icon-arrowthick-1-sw { background-position: -80px -48px; }
+.ui-icon-arrowthick-1-w { background-position: -96px -48px; }
+.ui-icon-arrowthick-1-nw { background-position: -112px -48px; }
+.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; }
+.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; }
+.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; }
+.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; }
+.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; }
+.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; }
+.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; }
+.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; }
+.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; }
+.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; }
+.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; }
+.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; }
+.ui-icon-arrowreturn-1-w { background-position: -64px -64px; }
+.ui-icon-arrowreturn-1-n { background-position: -80px -64px; }
+.ui-icon-arrowreturn-1-e { background-position: -96px -64px; }
+.ui-icon-arrowreturn-1-s { background-position: -112px -64px; }
+.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; }
+.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; }
+.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; }
+.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; }
+.ui-icon-arrow-4 { background-position: 0 -80px; }
+.ui-icon-arrow-4-diag { background-position: -16px -80px; }
+.ui-icon-extlink { background-position: -32px -80px; }
+.ui-icon-newwin { background-position: -48px -80px; }
+.ui-icon-refresh { background-position: -64px -80px; }
+.ui-icon-shuffle { background-position: -80px -80px; }
+.ui-icon-transfer-e-w { background-position: -96px -80px; }
+.ui-icon-transferthick-e-w { background-position: -112px -80px; }
+.ui-icon-folder-collapsed { background-position: 0 -96px; }
+.ui-icon-folder-open { background-position: -16px -96px; }
+.ui-icon-document { background-position: -32px -96px; }
+.ui-icon-document-b { background-position: -48px -96px; }
+.ui-icon-note { background-position: -64px -96px; }
+.ui-icon-mail-closed { background-position: -80px -96px; }
+.ui-icon-mail-open { background-position: -96px -96px; }
+.ui-icon-suitcase { background-position: -112px -96px; }
+.ui-icon-comment { background-position: -128px -96px; }
+.ui-icon-person { background-position: -144px -96px; }
+.ui-icon-print { background-position: -160px -96px; }
+.ui-icon-trash { background-position: -176px -96px; }
+.ui-icon-locked { background-position: -192px -96px; }
+.ui-icon-unlocked { background-position: -208px -96px; }
+.ui-icon-bookmark { background-position: -224px -96px; }
+.ui-icon-tag { background-position: -240px -96px; }
+.ui-icon-home { background-position: 0 -112px; }
+.ui-icon-flag { background-position: -16px -112px; }
+.ui-icon-calendar { background-position: -32px -112px; }
+.ui-icon-cart { background-position: -48px -112px; }
+.ui-icon-pencil { background-position: -64px -112px; }
+.ui-icon-clock { background-position: -80px -112px; }
+.ui-icon-disk { background-position: -96px -112px; }
+.ui-icon-calculator { background-position: -112px -112px; }
+.ui-icon-zoomin { background-position: -128px -112px; }
+.ui-icon-zoomout { background-position: -144px -112px; }
+.ui-icon-search { background-position: -160px -112px; }
+.ui-icon-wrench { background-position: -176px -112px; }
+.ui-icon-gear { background-position: -192px -112px; }
+.ui-icon-heart { background-position: -208px -112px; }
+.ui-icon-star { background-position: -224px -112px; }
+.ui-icon-link { background-position: -240px -112px; }
+.ui-icon-cancel { background-position: 0 -128px; }
+.ui-icon-plus { background-position: -16px -128px; }
+.ui-icon-plusthick { background-position: -32px -128px; }
+.ui-icon-minus { background-position: -48px -128px; }
+.ui-icon-minusthick { background-position: -64px -128px; }
+.ui-icon-close { background-position: -80px -128px; }
+.ui-icon-closethick { background-position: -96px -128px; }
+.ui-icon-key { background-position: -112px -128px; }
+.ui-icon-lightbulb { background-position: -128px -128px; }
+.ui-icon-scissors { background-position: -144px -128px; }
+.ui-icon-clipboard { background-position: -160px -128px; }
+.ui-icon-copy { background-position: -176px -128px; }
+.ui-icon-contact { background-position: -192px -128px; }
+.ui-icon-image { background-position: -208px -128px; }
+.ui-icon-video { background-position: -224px -128px; }
+.ui-icon-script { background-position: -240px -128px; }
+.ui-icon-alert { background-position: 0 -144px; }
+.ui-icon-info { background-position: -16px -144px; }
+.ui-icon-notice { background-position: -32px -144px; }
+.ui-icon-help { background-position: -48px -144px; }
+.ui-icon-check { background-position: -64px -144px; }
+.ui-icon-bullet { background-position: -80px -144px; }
+.ui-icon-radio-off { background-position: -96px -144px; }
+.ui-icon-radio-on { background-position: -112px -144px; }
+.ui-icon-pin-w { background-position: -128px -144px; }
+.ui-icon-pin-s { background-position: -144px -144px; }
+.ui-icon-play { background-position: 0 -160px; }
+.ui-icon-pause { background-position: -16px -160px; }
+.ui-icon-seek-next { background-position: -32px -160px; }
+.ui-icon-seek-prev { background-position: -48px -160px; }
+.ui-icon-seek-end { background-position: -64px -160px; }
+.ui-icon-seek-first { background-position: -80px -160px; }
+.ui-icon-stop { background-position: -96px -160px; }
+.ui-icon-eject { background-position: -112px -160px; }
+.ui-icon-volume-off { background-position: -128px -160px; }
+.ui-icon-volume-on { background-position: -144px -160px; }
+.ui-icon-power { background-position: 0 -176px; }
+.ui-icon-signal-diag { background-position: -16px -176px; }
+.ui-icon-signal { background-position: -32px -176px; }
+.ui-icon-battery-0 { background-position: -48px -176px; }
+.ui-icon-battery-1 { background-position: -64px -176px; }
+.ui-icon-battery-2 { background-position: -80px -176px; }
+.ui-icon-battery-3 { background-position: -96px -176px; }
+.ui-icon-circle-plus { background-position: 0 -192px; }
+.ui-icon-circle-minus { background-position: -16px -192px; }
+.ui-icon-circle-close { background-position: -32px -192px; }
+.ui-icon-circle-triangle-e { background-position: -48px -192px; }
+.ui-icon-circle-triangle-s { background-position: -64px -192px; }
+.ui-icon-circle-triangle-w { background-position: -80px -192px; }
+.ui-icon-circle-triangle-n { background-position: -96px -192px; }
+.ui-icon-circle-arrow-e { background-position: -112px -192px; }
+.ui-icon-circle-arrow-s { background-position: -128px -192px; }
+.ui-icon-circle-arrow-w { background-position: -144px -192px; }
+.ui-icon-circle-arrow-n { background-position: -160px -192px; }
+.ui-icon-circle-zoomin { background-position: -176px -192px; }
+.ui-icon-circle-zoomout { background-position: -192px -192px; }
+.ui-icon-circle-check { background-position: -208px -192px; }
+.ui-icon-circlesmall-plus { background-position: 0 -208px; }
+.ui-icon-circlesmall-minus { background-position: -16px -208px; }
+.ui-icon-circlesmall-close { background-position: -32px -208px; }
+.ui-icon-squaresmall-plus { background-position: -48px -208px; }
+.ui-icon-squaresmall-minus { background-position: -64px -208px; }
+.ui-icon-squaresmall-close { background-position: -80px -208px; }
+.ui-icon-grip-dotted-vertical { background-position: 0 -224px; }
+.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; }
+.ui-icon-grip-solid-vertical { background-position: -32px -224px; }
+.ui-icon-grip-solid-horizontal { background-position: -48px -224px; }
+.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; }
+.ui-icon-grip-diagonal-se { background-position: -80px -224px; }
+
+
+/* Misc visuals
+----------------------------------*/
+
+/* Corner radius */
+.ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; }
+.ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; }
+.ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; }
+.ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; }
+.ui-corner-top { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; }
+.ui-corner-bottom { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; }
+.ui-corner-right {  -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; }
+.ui-corner-left { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; }
+.ui-corner-all { -moz-border-radius: 4px; -webkit-border-radius: 4px; }
+
+/* Overlays */
+.ui-widget-overlay { background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); }
+.ui-widget-shadow { margin: -8px 0 0 -8px; padding: 8px; background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); -moz-border-radius: 8px; -webkit-border-radius: 8px; }/* Accordion
+----------------------------------*/
+.ui-accordion .ui-accordion-header { cursor: pointer; position: relative; margin-top: 1px; zoom: 1; }
+.ui-accordion .ui-accordion-li-fix { display: inline; }
+.ui-accordion .ui-accordion-header-active { border-bottom: 0 !important; }
+.ui-accordion .ui-accordion-header a { display: block; font-size: 1em; padding: .5em .5em .5em 2.2em; }
+.ui-accordion .ui-accordion-header .ui-icon { position: absolute; left: .5em; top: 50%; margin-top: -8px; }
+.ui-accordion .ui-accordion-content { padding: 1em 2.2em; border-top: 0; margin-top: -2px; position: relative; top: 1px; margin-bottom: 2px; overflow: auto; display: none; }
+.ui-accordion .ui-accordion-content-active { display: block; }/* Datepicker
+----------------------------------*/
+.ui-datepicker { width: 17em; padding: .2em .2em 0; }
+.ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; }
+.ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; }
+.ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; }
+.ui-datepicker .ui-datepicker-prev { left:2px; }
+.ui-datepicker .ui-datepicker-next { right:2px; }
+.ui-datepicker .ui-datepicker-prev-hover { left:1px; }
+.ui-datepicker .ui-datepicker-next-hover { right:1px; }
+.ui-datepicker .ui-datepicker-prev span, .ui-datepicker .ui-datepicker-next span { display: block; position: absolute; left: 50%; margin-left: -8px; top: 50%; margin-top: -8px;  }
+.ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; }
+.ui-datepicker .ui-datepicker-title select { float:left; font-size:1em; margin:1px 0; }
+.ui-datepicker select.ui-datepicker-month-year {width: 100%;}
+.ui-datepicker select.ui-datepicker-month, 
+.ui-datepicker select.ui-datepicker-year { width: 49%;}
+.ui-datepicker .ui-datepicker-title select.ui-datepicker-year { float: right; }
+.ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; }
+.ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0;  }
+.ui-datepicker td { border: 0; padding: 1px; }
+.ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; }
+.ui-datepicker .ui-datepicker-buttonpane { background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; }
+.ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
+.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; }
+
+/* with multiple calendars */
+.ui-datepicker.ui-datepicker-multi { width:auto; }
+.ui-datepicker-multi .ui-datepicker-group { float:left; }
+.ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; }
+.ui-datepicker-multi-2 .ui-datepicker-group { width:50%; }
+.ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; }
+.ui-datepicker-multi-4 .ui-datepicker-group { width:25%; }
+.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; }
+.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; }
+.ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; }
+.ui-datepicker-row-break { clear:both; width:100%; }
+
+/* RTL support */
+.ui-datepicker-rtl { direction: rtl; }
+.ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; }
+.ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; }
+.ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; }
+.ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; }
+.ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; }
+.ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; }
+.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; }
+.ui-datepicker-rtl .ui-datepicker-group { float:right; }
+.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
+.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
+
+/* IE6 IFRAME FIX (taken from datepicker 1.5.3 */
+.ui-datepicker-cover {
+    display: none; /*sorry for IE5*/
+    display/**/: block; /*sorry for IE5*/
+    position: absolute; /*must have*/
+    z-index: -1; /*must have*/
+    filter: mask(); /*must have*/
+    top: -4px; /*must have*/
+    left: -4px; /*must have*/
+    width: 200px; /*must have*/
+    height: 200px; /*must have*/
+}/* Dialog
+----------------------------------*/
+.ui-dialog { position: relative; padding: .2em; width: 300px; }
+.ui-dialog .ui-dialog-titlebar { padding: .5em .3em .3em 1em; position: relative;  }
+.ui-dialog .ui-dialog-title { float: left; margin: .1em 0 .2em; } 
+.ui-dialog .ui-dialog-titlebar-close { position: absolute; right: .3em; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; }
+.ui-dialog .ui-dialog-titlebar-close span { display: block; margin: 1px; }
+.ui-dialog .ui-dialog-titlebar-close:hover, .ui-dialog .ui-dialog-titlebar-close:focus { padding: 0; }
+.ui-dialog .ui-dialog-content { border: 0; padding: .5em 1em; background: none; overflow: auto; zoom: 1; }
+.ui-dialog .ui-dialog-buttonpane { text-align: left; border-width: 1px 0 0 0; background-image: none; margin: .5em 0 0 0; padding: .3em 1em .5em .4em; }
+.ui-dialog .ui-dialog-buttonpane button { float: right; margin: .5em .4em .5em 0; cursor: pointer; padding: .2em .6em .3em .6em; line-height: 1.4em; width:auto; overflow:visible; }
+.ui-dialog .ui-resizable-se { width: 14px; height: 14px; right: 3px; bottom: 3px; }
+.ui-draggable .ui-dialog-titlebar { cursor: move; }
+/* Progressbar
+----------------------------------*/
+.ui-progressbar { height:2em; text-align: left; }
+.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; }/* Resizable
+----------------------------------*/
+.ui-resizable { position: relative;}
+.ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block;}
+.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; }
+.ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0px; }
+.ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0px; }
+.ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0px; height: 100%; }
+.ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0px; height: 100%; }
+.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; }
+.ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; }
+.ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; }
+.ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/* Slider
+----------------------------------*/
+.ui-slider { position: relative; text-align: left; }
+.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; }
+.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; }
+
+.ui-slider-horizontal { height: .8em; }
+.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; }
+.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; }
+.ui-slider-horizontal .ui-slider-range-min { left: 0; }
+.ui-slider-horizontal .ui-slider-range-max { right: 0; }
+
+.ui-slider-vertical { width: .8em; height: 100px; }
+.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; }
+.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; }
+.ui-slider-vertical .ui-slider-range-min { bottom: 0; }
+.ui-slider-vertical .ui-slider-range-max { top: 0; }/* Tabs
+----------------------------------*/
+.ui-tabs { padding: .2em; zoom: 1; }
+.ui-tabs .ui-tabs-nav { list-style: none; position: relative; padding: .2em .2em 0; }
+.ui-tabs .ui-tabs-nav li { position: relative; float: left; border-bottom-width: 0 !important; margin: 0 .2em -1px 0; padding: 0; }
+.ui-tabs .ui-tabs-nav li a { float: left; text-decoration: none; padding: .5em 1em; }
+.ui-tabs .ui-tabs-nav li.ui-tabs-selected { padding-bottom: 1px; border-bottom-width: 0; }
+.ui-tabs .ui-tabs-nav li.ui-tabs-selected a, .ui-tabs .ui-tabs-nav li.ui-state-disabled a, .ui-tabs .ui-tabs-nav li.ui-state-processing a { cursor: text; }
+.ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { cursor: pointer; } /* first selector in group seems obsolete, but required to overcome bug in Opera applying cursor: text overall if defined elsewhere... */
+.ui-tabs .ui-tabs-panel { padding: 1em 1.4em; display: block; border-width: 0; background: none; }
+.ui-tabs .ui-tabs-hide { display: none !important; }
diff --git a/web/ext.php b/web/ext.php
new file mode 100644 (file)
index 0000000..d645381
--- /dev/null
@@ -0,0 +1,21 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+
+$pi = mtrack_get_pathinfo();
+
+$p = MTrackExtensionPage::bindToPage($pi);
+
+if ($p) {
+  $p->dispatchRequest();
+} else {
+
+  mtrack_head("Not found");
+
+  echo htmlentities($pi, ENT_QUOTES, 'utf-8');
+  echo " is not a registered mtrack application endpoint";
+
+  mtrack_foot();
+}
+
+
diff --git a/web/file.php b/web/file.php
new file mode 100644 (file)
index 0000000..f3c86f6
--- /dev/null
@@ -0,0 +1,190 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+MTrackACL::requireAllRights('Browser', 'read');
+
+$pi = mtrack_get_pathinfo(true);
+
+$data = explode('@', $pi);
+$pi = $data[0];
+if (isset($data[1])) {
+  $_GET['rev'] = $data[1];
+}
+
+$crumbs = MTrackSCM::makeBreadcrumbs($pi);
+if (!strlen($pi) || $pi == '/') {
+  $pi = '/';
+} else {
+  $repo = MTrackSCM::factory($pi);
+}
+
+if (!$repo) {
+  throw new Exception("invalid path $pi");
+}
+MTrackACL::requireAllRights("repo:$repo->repoid", 'read');
+
+if (isset($_GET['rev'])) {
+  $file = $repo->file($pi, 'rev', $_GET['rev']);
+} else {
+  $file = $repo->file($pi);
+}
+
+$ent = $file->getChangeEvent();
+
+if (isset($_GET['raw']) && $_GET['raw'] == 1) {
+  $filename = basename($pi);
+  header("Content-Type: application/octet-stream; name=\"$filename\"");
+  header("Content-Disposition: attachment; filename=\"$filename\"");
+  fpassthru($file->cat());
+  exit;
+}
+
+mtrack_head("File $pi @ " . $file->rev);
+
+/* Render a bread-crumb enabled location indicator */
+echo "<div class='browselocation'>Location: ";
+$location = null;
+$last = array_pop($crumbs);
+if (isset($_GET['jump'])) {
+  $jump = '?jump=' . urlencode($_GET['jump']);
+} else {
+  $jump = '';
+}
+foreach ($crumbs as $path) {
+  if (!strlen($path)) {
+    $path = '[root]';
+  } else {
+    $location .= '/' . urlencode($path);
+  }
+  $path = htmlentities($path, ENT_QUOTES, 'utf-8');
+  echo "<a href='{$ABSWEB}browse.php$location$jump'>$path</a> / ";
+}
+
+echo "$last @ " . mtrack_changeset($ent->rev, $repo);
+echo "</div>";
+
+echo "<div class='revinfo'>\n";
+echo MTrackWiki::format_to_html($ent->changelog);
+echo "<div class='changeinfo'>\n";
+echo mtrack_username($ent->changeby, array('size' => 32));
+echo "<br>\n";
+echo mtrack_date($ent->ctime, true) . "<br>\n";
+echo "Revision: $repo->shortname $ent->rev";
+foreach ($ent->branches as $b) {
+  echo " " . mtrack_branch($b);
+}
+foreach ($ent->tags as $t) {
+  echo " " . mtrack_tag($t);
+}
+echo "</div></div>\n";
+
+echo "<br><a href='{$ABSWEB}log.php/" .
+  $repo->getBrowseRootName() .
+  htmlentities("/$pi$jump", ENT_QUOTES, 'utf-8') .
+  "'>Show revision log</a>";
+
+/* Do we want to show the file? */
+
+$finfo = pathinfo($file->name);
+$t = tmpfile();
+
+$data = $file->cat();
+stream_copy_to_stream($data, $t);
+$data = null;
+
+$info = fstat($t);
+
+$location = stream_get_meta_data($t);
+$location = $location['uri'];
+
+$mimetype = mtrack_mime_detect($location, $file->name);
+list($major) = explode('/', $mimetype, 2);
+
+// Obscure-ish special cases for mime types;
+// some .y files look like old image format data
+if ($mimetype == 'image/x-3ds') {
+  $major = 'text';
+} elseif ($mimetype == 'application/xml') {
+  $major = 'text';
+}
+
+
+$p = $_GET;
+$p['raw'] = 1;
+$raw_url = $ABSWEB . 'file.php' . (isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '') .
+  '?' . http_build_query($p);
+
+if ($major == 'text') {
+  fseek($t, 0);
+  $ann = $file->annotate();
+  if ($ann === 'DELETED') {
+    echo "<div>Deleted</div>\n";
+  } else {
+    $i = 1;
+
+    $data = stream_get_contents($t);
+    $data = MTrackSyntaxHighlight::highlightSource($data, null, $file->name);
+
+    echo <<<HTML
+<br>
+<br>
+<button type='button' class='toggle-ann'>Blame</button>
+<button type='button' class='toggle-line'>Line #s</button>
+HTML;
+    echo MTrackSyntaxHighlight::getSchemeSelect();
+    echo <<<HTML
+<script>
+$(document).ready(function () {
+  var ann = false;
+  var line = true;
+  $('.toggle-ann').click(function () {
+    ann = !ann;
+    if (ann) {
+      $('table.codeann .user').show();
+      $('table.codeann .changeset').show();
+    } else {
+      $('table.codeann .user').hide();
+      $('table.codeann .changeset').hide();
+    }
+  });
+  $('.toggle-line').click(function () {
+    ann = !ann;
+    if (ann) {
+      $('table.codeann .line').show();
+    } else {
+      $('table.codeann .line').hide();
+    }
+  });
+});
+</script>
+HTML;
+
+    echo "<br><br><table class='codeann'><tr><th class='changeset'>rev</th><th class='user'>who</th><th class='line'>line</th><th class='code'>code</th></tr>\n";
+
+    while (isset($ann[$i])) {
+      $a = $ann[$i];
+      echo "<tr>" .
+        "<td class='changeset'>" . mtrack_changeset($a->rev, $repo) . "</td>" .
+        "<td class='user'>" . mtrack_username($a->changeby,
+            array('no_image' => true)) . "</td>" .
+        "<td class='line'><a name='l$i'></a><a href='#l$i'>$i</a></td>";
+
+      if ($i == 1) {
+        $nlines = count($ann);
+        echo "<td rowspan='$nlines' width='100%' class='source-code wezterm'>$data</td>";
+      }
+      echo "</tr>\n";
+
+      $i++;
+    }
+
+    echo "</table>\n";
+  }
+} elseif ($major == 'image') {
+  echo "<br><br><img src='$raw_url'>\n";
+}
+echo "<br><br><a href='$raw_url'>Download File</a> ($mimetype)\n";
+
+mtrack_foot();
+
diff --git a/web/help.php b/web/help.php
new file mode 100644 (file)
index 0000000..9b38117
--- /dev/null
@@ -0,0 +1,33 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+$topic = mtrack_get_pathinfo();
+$helpdir = dirname(__FILE__) . '/../defaults/help';
+if (strpos($topic, '.') !== false) {
+  throw new Exception("invalid help topic");
+}
+$name = $helpdir . '/' . $topic;
+
+if (!strlen($topic)) {
+  mtrack_head("Help topics");
+
+  echo "<h1>Help topics</h1>";
+  echo "<ul>\n";
+  foreach (glob("$helpdir/*") as $topic) {
+    $topic = basename($topic);
+    echo "<li><a href='{$ABSWEB}help.php/$topic'>$topic</a></li>\n";
+  }
+  echo "</ul>\n";
+
+} elseif (!file_exists($name)) {
+  mtrack_head("no help topic $topic");
+
+  echo "<h1>No Help topic ", htmlentities($topic), "</h1>";
+} else {
+  mtrack_head("Help: $topic");
+  echo MTrackWiki::format_to_html(file_get_contents($name));
+}
+
+mtrack_foot();
diff --git a/web/images/changeset.png b/web/images/changeset.png
new file mode 100644 (file)
index 0000000..31c0356
Binary files /dev/null and b/web/images/changeset.png differ
diff --git a/web/images/closedticket.png b/web/images/closedticket.png
new file mode 100644 (file)
index 0000000..43f7a84
Binary files /dev/null and b/web/images/closedticket.png differ
diff --git a/web/images/default_avatar.png b/web/images/default_avatar.png
new file mode 100755 (executable)
index 0000000..57a9a79
Binary files /dev/null and b/web/images/default_avatar.png differ
diff --git a/web/images/editedticket.png b/web/images/editedticket.png
new file mode 100644 (file)
index 0000000..9d12a91
Binary files /dev/null and b/web/images/editedticket.png differ
diff --git a/web/images/feed-icon-16x16.png b/web/images/feed-icon-16x16.png
new file mode 100644 (file)
index 0000000..1679ab0
Binary files /dev/null and b/web/images/feed-icon-16x16.png differ
diff --git a/web/images/file.png b/web/images/file.png
new file mode 100644 (file)
index 0000000..f35fc99
Binary files /dev/null and b/web/images/file.png differ
diff --git a/web/images/filedeny.png b/web/images/filedeny.png
new file mode 100644 (file)
index 0000000..f35fc99
Binary files /dev/null and b/web/images/filedeny.png differ
diff --git a/web/images/folder.png b/web/images/folder.png
new file mode 100644 (file)
index 0000000..d26c06c
Binary files /dev/null and b/web/images/folder.png differ
diff --git a/web/images/folderdeny.png b/web/images/folderdeny.png
new file mode 100644 (file)
index 0000000..d26c06c
Binary files /dev/null and b/web/images/folderdeny.png differ
diff --git a/web/images/gradient-footer.png b/web/images/gradient-footer.png
new file mode 100644 (file)
index 0000000..ae8097d
Binary files /dev/null and b/web/images/gradient-footer.png differ
diff --git a/web/images/gradient-header.png b/web/images/gradient-header.png
new file mode 100644 (file)
index 0000000..1016a28
Binary files /dev/null and b/web/images/gradient-header.png differ
diff --git a/web/images/logo_openid.png b/web/images/logo_openid.png
new file mode 100644 (file)
index 0000000..8a8a924
Binary files /dev/null and b/web/images/logo_openid.png differ
diff --git a/web/images/milestone.png b/web/images/milestone.png
new file mode 100644 (file)
index 0000000..e48a1d1
Binary files /dev/null and b/web/images/milestone.png differ
diff --git a/web/images/newticket.png b/web/images/newticket.png
new file mode 100644 (file)
index 0000000..cc973c4
Binary files /dev/null and b/web/images/newticket.png differ
diff --git a/web/images/parent.png b/web/images/parent.png
new file mode 100644 (file)
index 0000000..7ece298
Binary files /dev/null and b/web/images/parent.png differ
diff --git a/web/images/sort/asc.gif b/web/images/sort/asc.gif
new file mode 100755 (executable)
index 0000000..7415786
Binary files /dev/null and b/web/images/sort/asc.gif differ
diff --git a/web/images/sort/bg.gif b/web/images/sort/bg.gif
new file mode 100755 (executable)
index 0000000..fac668f
Binary files /dev/null and b/web/images/sort/bg.gif differ
diff --git a/web/images/sort/desc.gif b/web/images/sort/desc.gif
new file mode 100755 (executable)
index 0000000..3b30b3c
Binary files /dev/null and b/web/images/sort/desc.gif differ
diff --git a/web/images/treeview/file.gif b/web/images/treeview/file.gif
new file mode 100644 (file)
index 0000000..7e62167
Binary files /dev/null and b/web/images/treeview/file.gif differ
diff --git a/web/images/treeview/folder-closed.gif b/web/images/treeview/folder-closed.gif
new file mode 100644 (file)
index 0000000..5411078
Binary files /dev/null and b/web/images/treeview/folder-closed.gif differ
diff --git a/web/images/treeview/folder.gif b/web/images/treeview/folder.gif
new file mode 100644 (file)
index 0000000..2b31631
Binary files /dev/null and b/web/images/treeview/folder.gif differ
diff --git a/web/images/treeview/minus.gif b/web/images/treeview/minus.gif
new file mode 100644 (file)
index 0000000..47fb7b7
Binary files /dev/null and b/web/images/treeview/minus.gif differ
diff --git a/web/images/treeview/plus.gif b/web/images/treeview/plus.gif
new file mode 100644 (file)
index 0000000..6906621
Binary files /dev/null and b/web/images/treeview/plus.gif differ
diff --git a/web/images/treeview/treeview-black-line.gif b/web/images/treeview/treeview-black-line.gif
new file mode 100644 (file)
index 0000000..e549687
Binary files /dev/null and b/web/images/treeview/treeview-black-line.gif differ
diff --git a/web/images/treeview/treeview-black.gif b/web/images/treeview/treeview-black.gif
new file mode 100644 (file)
index 0000000..d549b9f
Binary files /dev/null and b/web/images/treeview/treeview-black.gif differ
diff --git a/web/images/treeview/treeview-default-line.gif b/web/images/treeview/treeview-default-line.gif
new file mode 100644 (file)
index 0000000..37114d3
Binary files /dev/null and b/web/images/treeview/treeview-default-line.gif differ
diff --git a/web/images/treeview/treeview-default.gif b/web/images/treeview/treeview-default.gif
new file mode 100644 (file)
index 0000000..a12ac52
Binary files /dev/null and b/web/images/treeview/treeview-default.gif differ
diff --git a/web/images/treeview/treeview-famfamfam-line.gif b/web/images/treeview/treeview-famfamfam-line.gif
new file mode 100644 (file)
index 0000000..6e289ce
Binary files /dev/null and b/web/images/treeview/treeview-famfamfam-line.gif differ
diff --git a/web/images/treeview/treeview-famfamfam.gif b/web/images/treeview/treeview-famfamfam.gif
new file mode 100644 (file)
index 0000000..0cb178e
Binary files /dev/null and b/web/images/treeview/treeview-famfamfam.gif differ
diff --git a/web/images/treeview/treeview-gray-line.gif b/web/images/treeview/treeview-gray-line.gif
new file mode 100644 (file)
index 0000000..3760044
Binary files /dev/null and b/web/images/treeview/treeview-gray-line.gif differ
diff --git a/web/images/treeview/treeview-gray.gif b/web/images/treeview/treeview-gray.gif
new file mode 100644 (file)
index 0000000..cfb8a2f
Binary files /dev/null and b/web/images/treeview/treeview-gray.gif differ
diff --git a/web/images/treeview/treeview-red-line.gif b/web/images/treeview/treeview-red-line.gif
new file mode 100644 (file)
index 0000000..df9e749
Binary files /dev/null and b/web/images/treeview/treeview-red-line.gif differ
diff --git a/web/images/treeview/treeview-red.gif b/web/images/treeview/treeview-red.gif
new file mode 100644 (file)
index 0000000..3bbb3a1
Binary files /dev/null and b/web/images/treeview/treeview-red.gif differ
diff --git a/web/images/wiki.png b/web/images/wiki.png
new file mode 100644 (file)
index 0000000..8a72b09
Binary files /dev/null and b/web/images/wiki.png differ
diff --git a/web/index.php b/web/index.php
new file mode 100644 (file)
index 0000000..b9639a3
--- /dev/null
@@ -0,0 +1,14 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+
+if (MTrackAuth::whoami() === 'anonymous') {
+  header("Location: {$ABSWEB}wiki.php");
+  exit;
+}
+
+mtrack_head("Today");
+echo MTrackWiki::format_wiki_page('Today');
+
+mtrack_foot();
+
diff --git a/web/js.php b/web/js.php
new file mode 100644 (file)
index 0000000..c4c8f55
--- /dev/null
@@ -0,0 +1,308 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+
+$age = 3600;
+
+header('Content-Type: text/javascript');
+header("Cache-Control: public, max-age=$age, pre-check=$age");
+header('Expires: ' . date(DATE_COOKIE, time() + $age));
+
+$scripts = array(
+  'excanvas.pack.js',
+  'jquery-1.4.2.min.js',
+  'jquery-ui-1.8.2.custom.min.js',
+  'jquery.asmselect.js',
+  'jquery.flot.pack.js',
+  'jquery.MultiFile.pack.js',
+  'jquery.cookie.js',
+  'jquery.treeview.js',
+  'jquery.tablesorter.js',
+  'jquery.metadata.js',
+  'jquery.markitup.js',
+  'jquery.timeago.js',
+  'json2.js',
+);
+
+echo "var ABSWEB = '$ABSWEB';\n";
+
+foreach ($scripts as $name) {
+  echo "\n// $name\n";
+  readfile("js/$name");
+  echo "\n;\n";
+}
+
+
+$PRI_SWITCH = '';
+foreach (MTrackDB::q('select priorityname, value from priorities')
+    ->fetchAll() as $row) {
+  $PRI_SWITCH .= "case '$row[0]': return $row[1];\n";
+}
+$SEV_SWITCH = '';
+foreach (MTrackDB::q('select sevname, ordinal from severities')
+    ->fetchAll() as $row) {
+  $SEV_SWITCH .= "case '$row[0]': return $row[1];\n";
+}
+
+echo <<<JAVASCRIPT
+$(document).ready(function() {
+  jQuery.timeago.settings.allowFuture = true;
+  $('abbr.timeinterval').timeago();
+  $("select[multiple]").asmSelect({
+    addItemTarget: 'bottom',
+    animate: false,
+    highlight: false,
+    removeLabel: '[x]',
+    sortable: false
+  });
+  if ($.browser.mozilla) {
+    // http://www.ryancramer.com/journal/entries/radio_buttons_firefox/
+    $("form").attr("autocomplete", "off");
+  }
+  $("textarea.wiki").markItUp({
+    nameSpace:          "wiki",
+    previewParserPath:  "{$ABSWEB}markitup-preview.php",
+    root: "{$ABSWEB}js",
+    onShiftEnter:       {keepDefault:false, replaceWith:'\\n\\n'},
+    markupSet:  [
+      {
+        name:'Heading 1', key:'1',
+        openWith:'== ', closeWith:' ==', placeHolder:'Your title here...'
+      },
+      {
+        name:'Heading 2', key:'2',
+        openWith:'=== ', closeWith:' ===', placeHolder:'Your title here...'
+      },
+      {
+        name:'Heading 3', key:'3',
+        openWith:'==== ', closeWith:' ====', placeHolder:'Your title here...'
+      },
+      {
+        name:'Heading 4', key:'4',
+        openWith:'===== ', closeWith:' =====', placeHolder:'Your title here...'
+      },
+      {
+        name:'Heading 5', key:'5',
+        openWith:'====== ', closeWith:' ======',
+        placeHolder:'Your title here...'
+      },
+      {separator:'---------------' },
+      {name:'Bold', key:'B', openWith:"'''", closeWith:"'''"},
+      {name:'Italic', key:'I', openWith:"''", closeWith:"''"},
+      {name:'Stroke through', key:'S', openWith:'~~', closeWith:'~~'},
+      {separator:'---------------' },
+      {name:'Bulleted list', openWith:' * '},
+      {name:'Numeric list', openWith:' 1. '},
+      {separator:'---------------' },
+      {name:'Quotes', openWith:'(!(> |!|>)!)'},
+      {name:'Code', openWith:'{{{\\n', closeWith:'\\n}}}'},
+      {separator:'---------------' },
+      {name:'Preview', call:'preview', className:'preview'}
+    ]
+});
+
+  $.tablesorter.addParser({
+    id: 'ticket',
+    is: function(s) {
+      return /^#\d+/.test(s);
+    },
+    format: function(s) {
+      return $.tablesorter.formatFloat(s.replace(new RegExp(/#/g), ''));
+    },
+    type: 'numeric'
+  });
+  $.tablesorter.addParser({
+    id: 'priority',
+    is: function(s) {
+      // don't auto-detect
+      return false;
+    },
+    format: function(s) {
+      switch (s) {
+        $PRI_SWITCH
+      }
+      return s;
+    },
+    type: 'numeric'
+  });
+  $.tablesorter.addParser({
+    id: 'severity',
+    is: function(s) {
+      // don't auto-detect
+      return false;
+    },
+    format: function(s) {
+      switch (s) {
+        $SEV_SWITCH
+      }
+      return s;
+    },
+    type: 'numeric'
+  });
+  $.tablesorter.addParser({
+    id: 'mtrackdate',
+    is: function(s) {
+      // don't auto-detect
+      return false;
+    },
+    format: function(s) {
+      // relies on the textExtraction routine below to pull a
+      // date/time string out of the title portion of the abbr tag
+      return $.tablesorter.formatFloat(new Date(s).getTime());
+    },
+    type: 'numeric'
+  });
+  $("table.report, table.wiki").tablesorter({
+    textExtraction: function(node) {
+      var kid = node.childNodes[0];
+      if (kid && kid.tagName == 'ABBR') {
+        // assuming that this abbr is of class='timeinterval'
+        return kid.title;
+      }
+      // default 'simple' behavior
+      if (kid && kid.hasChildNodes()) {
+        return kid.innerHTML;
+      }
+      return node.innerHTML;
+    }
+  });
+  $('input.search[type=text]').each(function () {
+    if ($.browser.webkit) {
+      this.type = 'search';
+      $(this).attr('autosave', ABSWEB);
+      $(this).attr('results', 5);
+    } else {
+      $(this).addClass('roundsearch');
+    }
+  });
+  // Convert links that are styled after buttons into actual buttons
+  $('a.button[href]').each(function () {
+    var href = $(this).attr('href');
+    var but = $('<button type="button"/>');
+    but.text($(this).text());
+    $(this).replaceWith(but);
+    but.click(function () {
+      document.location.href = href;
+      return false;
+    });
+  });
+
+  $.fn.mtrackWatermark = function () {
+    this.each(function () {
+      var ph = $(this).attr('title');
+      if ($.browser.webkit) {
+        // Use native safari placeholder for watermark
+        $(this).attr('placeholder', ph);
+      } else {
+        // http://plugins.jquery.com/files/jquery.tinywatermark-2.0.0.js.txt
+        var w;
+        var me = $(this);
+        me.focus(function () {
+          if (w) {
+            w = 0;
+            me.removeClass('watermark').data('w', 0).val('');
+          }
+        })
+        .blur(function () {
+          if (!me.val()) {
+            w = 1;
+            me.addClass('watermark').data('w', 1).val(ph);
+          }
+        })
+        .closest('form').submit(function () {
+          if (w) {
+            me.val('');
+          }
+        });
+        me.blur();
+      }
+    });
+  };
+  // Watermarking
+  $('input[title!=""]').mtrackWatermark();
+
+  // Toggle line number display in diff visualizations, to make it easier
+  // to copy the diff contents
+  var diff_visible = true;
+  $('.togglediffcopy').click(function () {
+    diff_visible = !diff_visible;
+    if (diff_visible) {
+      $('table.code.diff tr td.lineno').show();
+      $('table.code.diff tr td.linelink').show();
+    } else {
+      $('table.code.diff tr td.lineno').hide();
+      $('table.code.diff tr td.linelink').hide();
+    }
+  });
+
+  // Syntax highlighting
+  var hl_color_scheme = 'wezterm';
+  function applyhl(name) {
+    if (hl_color_scheme != '') {
+      $('.source-code').removeClass(hl_color_scheme);
+    }
+    if (name != '') {
+      $('.source-code').addClass(name);
+    }
+    hl_color_scheme = name;
+  }
+  $('.select-hl-scheme').change(function () {
+    applyhl($(this).val());
+    var val = $(this).val();
+    $('.select-hl-scheme').each(function () {
+      $(this).val(val);
+    });
+  });
+
+  // Arrange for the footer to sink to the bottom of the window, if the window
+  // contents are not very tall
+  var last_dh = 0;
+  var last_wh = 0;
+  function mtrack_footer_position(force) {
+    var ele = $('#footer');
+    if (!force &&
+        (last_dh != $(document).height() || last_wh != $(window).height)) {
+      force = true;
+    }
+    if (force) {
+      // Force a from-scratch layout assessment; put the footer back in
+      // it's natural location in the doc
+      ele.css({
+        position: "relative",
+        "margin-top": "3em",
+        top: 0,
+      });
+    }
+    if ($(document).height() <= $(window).height()) {
+      ele.css({
+        position: "absolute",
+        "margin-top": "0",
+        top: (
+            $(window).scrollTop() +
+            $(window).height() -
+            ele.height() - 1
+          )+"px"
+      });
+    } else {
+      ele.css({
+        position: "relative",
+        "margin-top": "3em"
+      });
+    }
+    last_dh = $(document).height();
+    last_wh = $(window).height();
+  }
+  window.mtrack_footer_position = mtrack_footer_position;
+  $(window)
+    .scroll(mtrack_footer_position)
+    .resize(mtrack_footer_position);
+  function mtrack_footer_set_and_wait() {
+    mtrack_footer_position();
+    setTimeout(function () {
+      mtrack_footer_set_and_wait();
+    }, 1500);
+  }
+  mtrack_footer_set_and_wait();
+});
+
+JAVASCRIPT;
diff --git a/web/js/excanvas.pack.js b/web/js/excanvas.pack.js
new file mode 100644 (file)
index 0000000..c40d6f7
--- /dev/null
@@ -0,0 +1,1427 @@
+// Copyright 2006 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+// Known Issues:
+//
+// * Patterns only support repeat.
+// * Radial gradient are not implemented. The VML version of these look very
+//   different from the canvas one.
+// * Clipping paths are not implemented.
+// * Coordsize. The width and height attribute have higher priority than the
+//   width and height style values which isn't correct.
+// * Painting mode isn't implemented.
+// * Canvas width/height should is using content-box by default. IE in
+//   Quirks mode will draw the canvas using border-box. Either change your
+//   doctype to HTML5
+//   (http://www.whatwg.org/specs/web-apps/current-work/#the-doctype)
+//   or use Box Sizing Behavior from WebFX
+//   (http://webfx.eae.net/dhtml/boxsizing/boxsizing.html)
+// * Non uniform scaling does not correctly scale strokes.
+// * Filling very large shapes (above 5000 points) is buggy.
+// * Optimize. There is always room for speed improvements.
+
+// Only add this code if we do not already have a canvas implementation
+if (!document.createElement('canvas').getContext) {
+
+(function() {
+
+  // alias some functions to make (compiled) code shorter
+  var m = Math;
+  var mr = m.round;
+  var ms = m.sin;
+  var mc = m.cos;
+  var abs = m.abs;
+  var sqrt = m.sqrt;
+
+  // this is used for sub pixel precision
+  var Z = 10;
+  var Z2 = Z / 2;
+
+  /**
+   * This funtion is assigned to the <canvas> elements as element.getContext().
+   * @this {HTMLElement}
+   * @return {CanvasRenderingContext2D_}
+   */
+  function getContext() {
+    return this.context_ ||
+        (this.context_ = new CanvasRenderingContext2D_(this));
+  }
+
+  var slice = Array.prototype.slice;
+
+  /**
+   * Binds a function to an object. The returned function will always use the
+   * passed in {@code obj} as {@code this}.
+   *
+   * Example:
+   *
+   *   g = bind(f, obj, a, b)
+   *   g(c, d) // will do f.call(obj, a, b, c, d)
+   *
+   * @param {Function} f The function to bind the object to
+   * @param {Object} obj The object that should act as this when the function
+   *     is called
+   * @param {*} var_args Rest arguments that will be used as the initial
+   *     arguments when the function is called
+   * @return {Function} A new function that has bound this
+   */
+  function bind(f, obj, var_args) {
+    var a = slice.call(arguments, 2);
+    return function() {
+      return f.apply(obj, a.concat(slice.call(arguments)));
+    };
+  }
+
+  function encodeHtmlAttribute(s) {
+    return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
+  }
+
+  function addNamespacesAndStylesheet(doc) {
+    // create xmlns
+    if (!doc.namespaces['g_vml_']) {
+      doc.namespaces.add('g_vml_', 'urn:schemas-microsoft-com:vml',
+                         '#default#VML');
+
+    }
+    if (!doc.namespaces['g_o_']) {
+      doc.namespaces.add('g_o_', 'urn:schemas-microsoft-com:office:office',
+                         '#default#VML');
+    }
+
+    // Setup default CSS.  Only add one style sheet per document
+    if (!doc.styleSheets['ex_canvas_']) {
+      var ss = doc.createStyleSheet();
+      ss.owningElement.id = 'ex_canvas_';
+      ss.cssText = 'canvas{display:inline-block;overflow:hidden;' +
+          // default size is 300x150 in Gecko and Opera
+          'text-align:left;width:300px;height:150px}';
+    }
+  }
+
+  // Add namespaces and stylesheet at startup.
+  addNamespacesAndStylesheet(document);
+
+  var G_vmlCanvasManager_ = {
+    init: function(opt_doc) {
+      if (/MSIE/.test(navigator.userAgent) && !window.opera) {
+        var doc = opt_doc || document;
+        // Create a dummy element so that IE will allow canvas elements to be
+        // recognized.
+        doc.createElement('canvas');
+        doc.attachEvent('onreadystatechange', bind(this.init_, this, doc));
+      }
+    },
+
+    init_: function(doc) {
+      // find all canvas elements
+      var els = doc.getElementsByTagName('canvas');
+      for (var i = 0; i < els.length; i++) {
+        this.initElement(els[i]);
+      }
+    },
+
+    /**
+     * Public initializes a canvas element so that it can be used as canvas
+     * element from now on. This is called automatically before the page is
+     * loaded but if you are creating elements using createElement you need to
+     * make sure this is called on the element.
+     * @param {HTMLElement} el The canvas element to initialize.
+     * @return {HTMLElement} the element that was created.
+     */
+    initElement: function(el) {
+      if (!el.getContext) {
+        el.getContext = getContext;
+
+        // Add namespaces and stylesheet to document of the element.
+        addNamespacesAndStylesheet(el.ownerDocument);
+
+        // Remove fallback content. There is no way to hide text nodes so we
+        // just remove all childNodes. We could hide all elements and remove
+        // text nodes but who really cares about the fallback content.
+        el.innerHTML = '';
+
+        // do not use inline function because that will leak memory
+        el.attachEvent('onpropertychange', onPropertyChange);
+        el.attachEvent('onresize', onResize);
+
+        var attrs = el.attributes;
+        if (attrs.width && attrs.width.specified) {
+          // TODO: use runtimeStyle and coordsize
+          // el.getContext().setWidth_(attrs.width.nodeValue);
+          el.style.width = attrs.width.nodeValue + 'px';
+        } else {
+          el.width = el.clientWidth;
+        }
+        if (attrs.height && attrs.height.specified) {
+          // TODO: use runtimeStyle and coordsize
+          // el.getContext().setHeight_(attrs.height.nodeValue);
+          el.style.height = attrs.height.nodeValue + 'px';
+        } else {
+          el.height = el.clientHeight;
+        }
+        //el.getContext().setCoordsize_()
+      }
+      return el;
+    }
+  };
+
+  function onPropertyChange(e) {
+    var el = e.srcElement;
+
+    switch (e.propertyName) {
+      case 'width':
+        el.getContext().clearRect();
+        el.style.width = el.attributes.width.nodeValue + 'px';
+        // In IE8 this does not trigger onresize.
+        el.firstChild.style.width =  el.clientWidth + 'px';
+        break;
+      case 'height':
+        el.getContext().clearRect();
+        el.style.height = el.attributes.height.nodeValue + 'px';
+        el.firstChild.style.height = el.clientHeight + 'px';
+        break;
+    }
+  }
+
+  function onResize(e) {
+    var el = e.srcElement;
+    if (el.firstChild) {
+      el.firstChild.style.width =  el.clientWidth + 'px';
+      el.firstChild.style.height = el.clientHeight + 'px';
+    }
+  }
+
+  G_vmlCanvasManager_.init();
+
+  // precompute "00" to "FF"
+  var decToHex = [];
+  for (var i = 0; i < 16; i++) {
+    for (var j = 0; j < 16; j++) {
+      decToHex[i * 16 + j] = i.toString(16) + j.toString(16);
+    }
+  }
+
+  function createMatrixIdentity() {
+    return [
+      [1, 0, 0],
+      [0, 1, 0],
+      [0, 0, 1]
+    ];
+  }
+
+  function matrixMultiply(m1, m2) {
+    var result = createMatrixIdentity();
+
+    for (var x = 0; x < 3; x++) {
+      for (var y = 0; y < 3; y++) {
+        var sum = 0;
+
+        for (var z = 0; z < 3; z++) {
+          sum += m1[x][z] * m2[z][y];
+        }
+
+        result[x][y] = sum;
+      }
+    }
+    return result;
+  }
+
+  function copyState(o1, o2) {
+    o2.fillStyle     = o1.fillStyle;
+    o2.lineCap       = o1.lineCap;
+    o2.lineJoin      = o1.lineJoin;
+    o2.lineWidth     = o1.lineWidth;
+    o2.miterLimit    = o1.miterLimit;
+    o2.shadowBlur    = o1.shadowBlur;
+    o2.shadowColor   = o1.shadowColor;
+    o2.shadowOffsetX = o1.shadowOffsetX;
+    o2.shadowOffsetY = o1.shadowOffsetY;
+    o2.strokeStyle   = o1.strokeStyle;
+    o2.globalAlpha   = o1.globalAlpha;
+    o2.font          = o1.font;
+    o2.textAlign     = o1.textAlign;
+    o2.textBaseline  = o1.textBaseline;
+    o2.arcScaleX_    = o1.arcScaleX_;
+    o2.arcScaleY_    = o1.arcScaleY_;
+    o2.lineScale_    = o1.lineScale_;
+  }
+
+  var colorData = {
+    aliceblue: '#F0F8FF',
+    antiquewhite: '#FAEBD7',
+    aquamarine: '#7FFFD4',
+    azure: '#F0FFFF',
+    beige: '#F5F5DC',
+    bisque: '#FFE4C4',
+    black: '#000000',
+    blanchedalmond: '#FFEBCD',
+    blueviolet: '#8A2BE2',
+    brown: '#A52A2A',
+    burlywood: '#DEB887',
+    cadetblue: '#5F9EA0',
+    chartreuse: '#7FFF00',
+    chocolate: '#D2691E',
+    coral: '#FF7F50',
+    cornflowerblue: '#6495ED',
+    cornsilk: '#FFF8DC',
+    crimson: '#DC143C',
+    cyan: '#00FFFF',
+    darkblue: '#00008B',
+    darkcyan: '#008B8B',
+    darkgoldenrod: '#B8860B',
+    darkgray: '#A9A9A9',
+    darkgreen: '#006400',
+    darkgrey: '#A9A9A9',
+    darkkhaki: '#BDB76B',
+    darkmagenta: '#8B008B',
+    darkolivegreen: '#556B2F',
+    darkorange: '#FF8C00',
+    darkorchid: '#9932CC',
+    darkred: '#8B0000',
+    darksalmon: '#E9967A',
+    darkseagreen: '#8FBC8F',
+    darkslateblue: '#483D8B',
+    darkslategray: '#2F4F4F',
+    darkslategrey: '#2F4F4F',
+    darkturquoise: '#00CED1',
+    darkviolet: '#9400D3',
+    deeppink: '#FF1493',
+    deepskyblue: '#00BFFF',
+    dimgray: '#696969',
+    dimgrey: '#696969',
+    dodgerblue: '#1E90FF',
+    firebrick: '#B22222',
+    floralwhite: '#FFFAF0',
+    forestgreen: '#228B22',
+    gainsboro: '#DCDCDC',
+    ghostwhite: '#F8F8FF',
+    gold: '#FFD700',
+    goldenrod: '#DAA520',
+    grey: '#808080',
+    greenyellow: '#ADFF2F',
+    honeydew: '#F0FFF0',
+    hotpink: '#FF69B4',
+    indianred: '#CD5C5C',
+    indigo: '#4B0082',
+    ivory: '#FFFFF0',
+    khaki: '#F0E68C',
+    lavender: '#E6E6FA',
+    lavenderblush: '#FFF0F5',
+    lawngreen: '#7CFC00',
+    lemonchiffon: '#FFFACD',
+    lightblue: '#ADD8E6',
+    lightcoral: '#F08080',
+    lightcyan: '#E0FFFF',
+    lightgoldenrodyellow: '#FAFAD2',
+    lightgreen: '#90EE90',
+    lightgrey: '#D3D3D3',
+    lightpink: '#FFB6C1',
+    lightsalmon: '#FFA07A',
+    lightseagreen: '#20B2AA',
+    lightskyblue: '#87CEFA',
+    lightslategray: '#778899',
+    lightslategrey: '#778899',
+    lightsteelblue: '#B0C4DE',
+    lightyellow: '#FFFFE0',
+    limegreen: '#32CD32',
+    linen: '#FAF0E6',
+    magenta: '#FF00FF',
+    mediumaquamarine: '#66CDAA',
+    mediumblue: '#0000CD',
+    mediumorchid: '#BA55D3',
+    mediumpurple: '#9370DB',
+    mediumseagreen: '#3CB371',
+    mediumslateblue: '#7B68EE',
+    mediumspringgreen: '#00FA9A',
+    mediumturquoise: '#48D1CC',
+    mediumvioletred: '#C71585',
+    midnightblue: '#191970',
+    mintcream: '#F5FFFA',
+    mistyrose: '#FFE4E1',
+    moccasin: '#FFE4B5',
+    navajowhite: '#FFDEAD',
+    oldlace: '#FDF5E6',
+    olivedrab: '#6B8E23',
+    orange: '#FFA500',
+    orangered: '#FF4500',
+    orchid: '#DA70D6',
+    palegoldenrod: '#EEE8AA',
+    palegreen: '#98FB98',
+    paleturquoise: '#AFEEEE',
+    palevioletred: '#DB7093',
+    papayawhip: '#FFEFD5',
+    peachpuff: '#FFDAB9',
+    peru: '#CD853F',
+    pink: '#FFC0CB',
+    plum: '#DDA0DD',
+    powderblue: '#B0E0E6',
+    rosybrown: '#BC8F8F',
+    royalblue: '#4169E1',
+    saddlebrown: '#8B4513',
+    salmon: '#FA8072',
+    sandybrown: '#F4A460',
+    seagreen: '#2E8B57',
+    seashell: '#FFF5EE',
+    sienna: '#A0522D',
+    skyblue: '#87CEEB',
+    slateblue: '#6A5ACD',
+    slategray: '#708090',
+    slategrey: '#708090',
+    snow: '#FFFAFA',
+    springgreen: '#00FF7F',
+    steelblue: '#4682B4',
+    tan: '#D2B48C',
+    thistle: '#D8BFD8',
+    tomato: '#FF6347',
+    turquoise: '#40E0D0',
+    violet: '#EE82EE',
+    wheat: '#F5DEB3',
+    whitesmoke: '#F5F5F5',
+    yellowgreen: '#9ACD32'
+  };
+
+
+  function getRgbHslContent(styleString) {
+    var start = styleString.indexOf('(', 3);
+    var end = styleString.indexOf(')', start + 1);
+    var parts = styleString.substring(start + 1, end).split(',');
+    // add alpha if needed
+    if (parts.length == 4 && styleString.substr(3, 1) == 'a') {
+      alpha = Number(parts[3]);
+    } else {
+      parts[3] = 1;
+    }
+    return parts;
+  }
+
+  function percent(s) {
+    return parseFloat(s) / 100;
+  }
+
+  function clamp(v, min, max) {
+    return Math.min(max, Math.max(min, v));
+  }
+
+  function hslToRgb(parts){
+    var r, g, b;
+    h = parseFloat(parts[0]) / 360 % 360;
+    if (h < 0)
+      h++;
+    s = clamp(percent(parts[1]), 0, 1);
+    l = clamp(percent(parts[2]), 0, 1);
+    if (s == 0) {
+      r = g = b = l; // achromatic
+    } else {
+      var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+      var p = 2 * l - q;
+      r = hueToRgb(p, q, h + 1 / 3);
+      g = hueToRgb(p, q, h);
+      b = hueToRgb(p, q, h - 1 / 3);
+    }
+
+    return '#' + decToHex[Math.floor(r * 255)] +
+        decToHex[Math.floor(g * 255)] +
+        decToHex[Math.floor(b * 255)];
+  }
+
+  function hueToRgb(m1, m2, h) {
+    if (h < 0)
+      h++;
+    if (h > 1)
+      h--;
+
+    if (6 * h < 1)
+      return m1 + (m2 - m1) * 6 * h;
+    else if (2 * h < 1)
+      return m2;
+    else if (3 * h < 2)
+      return m1 + (m2 - m1) * (2 / 3 - h) * 6;
+    else
+      return m1;
+  }
+
+  function processStyle(styleString) {
+    var str, alpha = 1;
+
+    styleString = String(styleString);
+    if (styleString.charAt(0) == '#') {
+      str = styleString;
+    } else if (/^rgb/.test(styleString)) {
+      var parts = getRgbHslContent(styleString);
+      var str = '#', n;
+      for (var i = 0; i < 3; i++) {
+        if (parts[i].indexOf('%') != -1) {
+          n = Math.floor(percent(parts[i]) * 255);
+        } else {
+          n = Number(parts[i]);
+        }
+        str += decToHex[clamp(n, 0, 255)];
+      }
+      alpha = parts[3];
+    } else if (/^hsl/.test(styleString)) {
+      var parts = getRgbHslContent(styleString);
+      str = hslToRgb(parts);
+      alpha = parts[3];
+    } else {
+      str = colorData[styleString] || styleString;
+    }
+    return {color: str, alpha: alpha};
+  }
+
+  var DEFAULT_STYLE = {
+    style: 'normal',
+    variant: 'normal',
+    weight: 'normal',
+    size: 10,
+    family: 'sans-serif'
+  };
+
+  // Internal text style cache
+  var fontStyleCache = {};
+
+  function processFontStyle(styleString) {
+    if (fontStyleCache[styleString]) {
+      return fontStyleCache[styleString];
+    }
+
+    var el = document.createElement('div');
+    var style = el.style;
+    try {
+      style.font = styleString;
+    } catch (ex) {
+      // Ignore failures to set to invalid font.
+    }
+
+    return fontStyleCache[styleString] = {
+      style: style.fontStyle || DEFAULT_STYLE.style,
+      variant: style.fontVariant || DEFAULT_STYLE.variant,
+      weight: style.fontWeight || DEFAULT_STYLE.weight,
+      size: style.fontSize || DEFAULT_STYLE.size,
+      family: style.fontFamily || DEFAULT_STYLE.family
+    };
+  }
+
+  function getComputedStyle(style, element) {
+    var computedStyle = {};
+
+    for (var p in style) {
+      computedStyle[p] = style[p];
+    }
+
+    // Compute the size
+    var canvasFontSize = parseFloat(element.currentStyle.fontSize),
+        fontSize = parseFloat(style.size);
+
+    if (typeof style.size == 'number') {
+      computedStyle.size = style.size;
+    } else if (style.size.indexOf('px') != -1) {
+      computedStyle.size = fontSize;
+    } else if (style.size.indexOf('em') != -1) {
+      computedStyle.size = canvasFontSize * fontSize;
+    } else if(style.size.indexOf('%') != -1) {
+      computedStyle.size = (canvasFontSize / 100) * fontSize;
+    } else if (style.size.indexOf('pt') != -1) {
+      computedStyle.size = fontSize / .75;
+    } else {
+      computedStyle.size = canvasFontSize;
+    }
+
+    // Different scaling between normal text and VML text. This was found using
+    // trial and error to get the same size as non VML text.
+    computedStyle.size *= 0.981;
+
+    return computedStyle;
+  }
+
+  function buildStyle(style) {
+    return style.style + ' ' + style.variant + ' ' + style.weight + ' ' +
+        style.size + 'px ' + style.family;
+  }
+
+  function processLineCap(lineCap) {
+    switch (lineCap) {
+      case 'butt':
+        return 'flat';
+      case 'round':
+        return 'round';
+      case 'square':
+      default:
+        return 'square';
+    }
+  }
+
+  /**
+   * This class implements CanvasRenderingContext2D interface as described by
+   * the WHATWG.
+   * @param {HTMLElement} surfaceElement The element that the 2D context should
+   * be associated with
+   */
+  function CanvasRenderingContext2D_(surfaceElement) {
+    this.m_ = createMatrixIdentity();
+
+    this.mStack_ = [];
+    this.aStack_ = [];
+    this.currentPath_ = [];
+
+    // Canvas context properties
+    this.strokeStyle = '#000';
+    this.fillStyle = '#000';
+
+    this.lineWidth = 1;
+    this.lineJoin = 'miter';
+    this.lineCap = 'butt';
+    this.miterLimit = Z * 1;
+    this.globalAlpha = 1;
+    this.font = '10px sans-serif';
+    this.textAlign = 'left';
+    this.textBaseline = 'alphabetic';
+    this.canvas = surfaceElement;
+
+    var el = surfaceElement.ownerDocument.createElement('div');
+    el.style.width =  surfaceElement.clientWidth + 'px';
+    el.style.height = surfaceElement.clientHeight + 'px';
+    el.style.overflow = 'hidden';
+    el.style.position = 'absolute';
+    surfaceElement.appendChild(el);
+
+    this.element_ = el;
+    this.arcScaleX_ = 1;
+    this.arcScaleY_ = 1;
+    this.lineScale_ = 1;
+  }
+
+  var contextPrototype = CanvasRenderingContext2D_.prototype;
+  contextPrototype.clearRect = function() {
+    if (this.textMeasureEl_) {
+      this.textMeasureEl_.removeNode(true);
+      this.textMeasureEl_ = null;
+    }
+    this.element_.innerHTML = '';
+  };
+
+  contextPrototype.beginPath = function() {
+    // TODO: Branch current matrix so that save/restore has no effect
+    //       as per safari docs.
+    this.currentPath_ = [];
+  };
+
+  contextPrototype.moveTo = function(aX, aY) {
+    var p = this.getCoords_(aX, aY);
+    this.currentPath_.push({type: 'moveTo', x: p.x, y: p.y});
+    this.currentX_ = p.x;
+    this.currentY_ = p.y;
+  };
+
+  contextPrototype.lineTo = function(aX, aY) {
+    var p = this.getCoords_(aX, aY);
+    this.currentPath_.push({type: 'lineTo', x: p.x, y: p.y});
+
+    this.currentX_ = p.x;
+    this.currentY_ = p.y;
+  };
+
+  contextPrototype.bezierCurveTo = function(aCP1x, aCP1y,
+                                            aCP2x, aCP2y,
+                                            aX, aY) {
+    var p = this.getCoords_(aX, aY);
+    var cp1 = this.getCoords_(aCP1x, aCP1y);
+    var cp2 = this.getCoords_(aCP2x, aCP2y);
+    bezierCurveTo(this, cp1, cp2, p);
+  };
+
+  // Helper function that takes the already fixed cordinates.
+  function bezierCurveTo(self, cp1, cp2, p) {
+    self.currentPath_.push({
+      type: 'bezierCurveTo',
+      cp1x: cp1.x,
+      cp1y: cp1.y,
+      cp2x: cp2.x,
+      cp2y: cp2.y,
+      x: p.x,
+      y: p.y
+    });
+    self.currentX_ = p.x;
+    self.currentY_ = p.y;
+  }
+
+  contextPrototype.quadraticCurveTo = function(aCPx, aCPy, aX, aY) {
+    // the following is lifted almost directly from
+    // http://developer.mozilla.org/en/docs/Canvas_tutorial:Drawing_shapes
+
+    var cp = this.getCoords_(aCPx, aCPy);
+    var p = this.getCoords_(aX, aY);
+
+    var cp1 = {
+      x: this.currentX_ + 2.0 / 3.0 * (cp.x - this.currentX_),
+      y: this.currentY_ + 2.0 / 3.0 * (cp.y - this.currentY_)
+    };
+    var cp2 = {
+      x: cp1.x + (p.x - this.currentX_) / 3.0,
+      y: cp1.y + (p.y - this.currentY_) / 3.0
+    };
+
+    bezierCurveTo(this, cp1, cp2, p);
+  };
+
+  contextPrototype.arc = function(aX, aY, aRadius,
+                                  aStartAngle, aEndAngle, aClockwise) {
+    aRadius *= Z;
+    var arcType = aClockwise ? 'at' : 'wa';
+
+    var xStart = aX + mc(aStartAngle) * aRadius - Z2;
+    var yStart = aY + ms(aStartAngle) * aRadius - Z2;
+
+    var xEnd = aX + mc(aEndAngle) * aRadius - Z2;
+    var yEnd = aY + ms(aEndAngle) * aRadius - Z2;
+
+    // IE won't render arches drawn counter clockwise if xStart == xEnd.
+    if (xStart == xEnd && !aClockwise) {
+      xStart += 0.125; // Offset xStart by 1/80 of a pixel. Use something
+                       // that can be represented in binary
+    }
+
+    var p = this.getCoords_(aX, aY);
+    var pStart = this.getCoords_(xStart, yStart);
+    var pEnd = this.getCoords_(xEnd, yEnd);
+
+    this.currentPath_.push({type: arcType,
+                           x: p.x,
+                           y: p.y,
+                           radius: aRadius,
+                           xStart: pStart.x,
+                           yStart: pStart.y,
+                           xEnd: pEnd.x,
+                           yEnd: pEnd.y});
+
+  };
+
+  contextPrototype.rect = function(aX, aY, aWidth, aHeight) {
+    this.moveTo(aX, aY);
+    this.lineTo(aX + aWidth, aY);
+    this.lineTo(aX + aWidth, aY + aHeight);
+    this.lineTo(aX, aY + aHeight);
+    this.closePath();
+  };
+
+  contextPrototype.strokeRect = function(aX, aY, aWidth, aHeight) {
+    var oldPath = this.currentPath_;
+    this.beginPath();
+
+    this.moveTo(aX, aY);
+    this.lineTo(aX + aWidth, aY);
+    this.lineTo(aX + aWidth, aY + aHeight);
+    this.lineTo(aX, aY + aHeight);
+    this.closePath();
+    this.stroke();
+
+    this.currentPath_ = oldPath;
+  };
+
+  contextPrototype.fillRect = function(aX, aY, aWidth, aHeight) {
+    var oldPath = this.currentPath_;
+    this.beginPath();
+
+    this.moveTo(aX, aY);
+    this.lineTo(aX + aWidth, aY);
+    this.lineTo(aX + aWidth, aY + aHeight);
+    this.lineTo(aX, aY + aHeight);
+    this.closePath();
+    this.fill();
+
+    this.currentPath_ = oldPath;
+  };
+
+  contextPrototype.createLinearGradient = function(aX0, aY0, aX1, aY1) {
+    var gradient = new CanvasGradient_('gradient');
+    gradient.x0_ = aX0;
+    gradient.y0_ = aY0;
+    gradient.x1_ = aX1;
+    gradient.y1_ = aY1;
+    return gradient;
+  };
+
+  contextPrototype.createRadialGradient = function(aX0, aY0, aR0,
+                                                   aX1, aY1, aR1) {
+    var gradient = new CanvasGradient_('gradientradial');
+    gradient.x0_ = aX0;
+    gradient.y0_ = aY0;
+    gradient.r0_ = aR0;
+    gradient.x1_ = aX1;
+    gradient.y1_ = aY1;
+    gradient.r1_ = aR1;
+    return gradient;
+  };
+
+  contextPrototype.drawImage = function(image, var_args) {
+    var dx, dy, dw, dh, sx, sy, sw, sh;
+
+    // to find the original width we overide the width and height
+    var oldRuntimeWidth = image.runtimeStyle.width;
+    var oldRuntimeHeight = image.runtimeStyle.height;
+    image.runtimeStyle.width = 'auto';
+    image.runtimeStyle.height = 'auto';
+
+    // get the original size
+    var w = image.width;
+    var h = image.height;
+
+    // and remove overides
+    image.runtimeStyle.width = oldRuntimeWidth;
+    image.runtimeStyle.height = oldRuntimeHeight;
+
+    if (arguments.length == 3) {
+      dx = arguments[1];
+      dy = arguments[2];
+      sx = sy = 0;
+      sw = dw = w;
+      sh = dh = h;
+    } else if (arguments.length == 5) {
+      dx = arguments[1];
+      dy = arguments[2];
+      dw = arguments[3];
+      dh = arguments[4];
+      sx = sy = 0;
+      sw = w;
+      sh = h;
+    } else if (arguments.length == 9) {
+      sx = arguments[1];
+      sy = arguments[2];
+      sw = arguments[3];
+      sh = arguments[4];
+      dx = arguments[5];
+      dy = arguments[6];
+      dw = arguments[7];
+      dh = arguments[8];
+    } else {
+      throw Error('Invalid number of arguments');
+    }
+
+    var d = this.getCoords_(dx, dy);
+
+    var w2 = sw / 2;
+    var h2 = sh / 2;
+
+    var vmlStr = [];
+
+    var W = 10;
+    var H = 10;
+
+    // For some reason that I've now forgotten, using divs didn't work
+    vmlStr.push(' <g_vml_:group',
+                ' coordsize="', Z * W, ',', Z * H, '"',
+                ' coordorigin="0,0"' ,
+                ' style="width:', W, 'px;height:', H, 'px;position:absolute;');
+
+    // If filters are necessary (rotation exists), create them
+    // filters are bog-slow, so only create them if abbsolutely necessary
+    // The following check doesn't account for skews (which don't exist
+    // in the canvas spec (yet) anyway.
+
+    if (this.m_[0][0] != 1 || this.m_[0][1] ||
+        this.m_[1][1] != 1 || this.m_[1][0]) {
+      var filter = [];
+
+      // Note the 12/21 reversal
+      filter.push('M11=', this.m_[0][0], ',',
+                  'M12=', this.m_[1][0], ',',
+                  'M21=', this.m_[0][1], ',',
+                  'M22=', this.m_[1][1], ',',
+                  'Dx=', mr(d.x / Z), ',',
+                  'Dy=', mr(d.y / Z), '');
+
+      // Bounding box calculation (need to minimize displayed area so that
+      // filters don't waste time on unused pixels.
+      var max = d;
+      var c2 = this.getCoords_(dx + dw, dy);
+      var c3 = this.getCoords_(dx, dy + dh);
+      var c4 = this.getCoords_(dx + dw, dy + dh);
+
+      max.x = m.max(max.x, c2.x, c3.x, c4.x);
+      max.y = m.max(max.y, c2.y, c3.y, c4.y);
+
+      vmlStr.push('padding:0 ', mr(max.x / Z), 'px ', mr(max.y / Z),
+                  'px 0;filter:progid:DXImageTransform.Microsoft.Matrix(',
+                  filter.join(''), ", sizingmethod='clip');");
+
+    } else {
+      vmlStr.push('top:', mr(d.y / Z), 'px;left:', mr(d.x / Z), 'px;');
+    }
+
+    vmlStr.push(' ">' ,
+                '<g_vml_:image src="', image.src, '"',
+                ' style="width:', Z * dw, 'px;',
+                ' height:', Z * dh, 'px"',
+                ' cropleft="', sx / w, '"',
+                ' croptop="', sy / h, '"',
+                ' cropright="', (w - sx - sw) / w, '"',
+                ' cropbottom="', (h - sy - sh) / h, '"',
+                ' />',
+                '</g_vml_:group>');
+
+    this.element_.insertAdjacentHTML('BeforeEnd', vmlStr.join(''));
+  };
+
+  contextPrototype.stroke = function(aFill) {
+    var W = 10;
+    var H = 10;
+    // Divide the shape into chunks if it's too long because IE has a limit
+    // somewhere for how long a VML shape can be. This simple division does
+    // not work with fills, only strokes, unfortunately.
+    var chunkSize = 5000;
+
+    var min = {x: null, y: null};
+    var max = {x: null, y: null};
+
+    for (var j = 0; j < this.currentPath_.length; j += chunkSize) {
+      var lineStr = [];
+      var lineOpen = false;
+
+      lineStr.push('<g_vml_:shape',
+                   ' filled="', !!aFill, '"',
+                   ' style="position:absolute;width:', W, 'px;height:', H, 'px;"',
+                   ' coordorigin="0,0"',
+                   ' coordsize="', Z * W, ',', Z * H, '"',
+                   ' stroked="', !aFill, '"',
+                   ' path="');
+
+      var newSeq = false;
+
+      for (var i = j; i < Math.min(j + chunkSize, this.currentPath_.length); i++) {
+        if (i % chunkSize == 0 && i > 0) { // move into position for next chunk
+          lineStr.push(' m ', mr(this.currentPath_[i-1].x), ',', mr(this.currentPath_[i-1].y));
+        }
+
+        var p = this.currentPath_[i];
+        var c;
+
+        switch (p.type) {
+          case 'moveTo':
+            c = p;
+            lineStr.push(' m ', mr(p.x), ',', mr(p.y));
+            break;
+          case 'lineTo':
+            lineStr.push(' l ', mr(p.x), ',', mr(p.y));
+            break;
+          case 'close':
+            lineStr.push(' x ');
+            p = null;
+            break;
+          case 'bezierCurveTo':
+            lineStr.push(' c ',
+                         mr(p.cp1x), ',', mr(p.cp1y), ',',
+                         mr(p.cp2x), ',', mr(p.cp2y), ',',
+                         mr(p.x), ',', mr(p.y));
+            break;
+          case 'at':
+          case 'wa':
+            lineStr.push(' ', p.type, ' ',
+                         mr(p.x - this.arcScaleX_ * p.radius), ',',
+                         mr(p.y - this.arcScaleY_ * p.radius), ' ',
+                         mr(p.x + this.arcScaleX_ * p.radius), ',',
+                         mr(p.y + this.arcScaleY_ * p.radius), ' ',
+                         mr(p.xStart), ',', mr(p.yStart), ' ',
+                         mr(p.xEnd), ',', mr(p.yEnd));
+            break;
+        }
+  
+  
+        // TODO: Following is broken for curves due to
+        //       move to proper paths.
+  
+        // Figure out dimensions so we can do gradient fills
+        // properly
+        if (p) {
+          if (min.x == null || p.x < min.x) {
+            min.x = p.x;
+          }
+          if (max.x == null || p.x > max.x) {
+            max.x = p.x;
+          }
+          if (min.y == null || p.y < min.y) {
+            min.y = p.y;
+          }
+          if (max.y == null || p.y > max.y) {
+            max.y = p.y;
+          }
+        }
+      }
+      lineStr.push(' ">');
+  
+      if (!aFill) {
+        appendStroke(this, lineStr);
+      } else {
+        appendFill(this, lineStr, min, max);
+      }
+  
+      lineStr.push('</g_vml_:shape>');
+  
+      this.element_.insertAdjacentHTML('beforeEnd', lineStr.join(''));
+    }
+  };
+
+  function appendStroke(ctx, lineStr) {
+    var a = processStyle(ctx.strokeStyle);
+    var color = a.color;
+    var opacity = a.alpha * ctx.globalAlpha;
+    var lineWidth = ctx.lineScale_ * ctx.lineWidth;
+
+    // VML cannot correctly render a line if the width is less than 1px.
+    // In that case, we dilute the color to make the line look thinner.
+    if (lineWidth < 1) {
+      opacity *= lineWidth;
+    }
+
+    lineStr.push(
+      '<g_vml_:stroke',
+      ' opacity="', opacity, '"',
+      ' joinstyle="', ctx.lineJoin, '"',
+      ' miterlimit="', ctx.miterLimit, '"',
+      ' endcap="', processLineCap(ctx.lineCap), '"',
+      ' weight="', lineWidth, 'px"',
+      ' color="', color, '" />'
+    );
+  }
+
+  function appendFill(ctx, lineStr, min, max) {
+    var fillStyle = ctx.fillStyle;
+    var arcScaleX = ctx.arcScaleX_;
+    var arcScaleY = ctx.arcScaleY_;
+    var width = max.x - min.x;
+    var height = max.y - min.y;
+    if (fillStyle instanceof CanvasGradient_) {
+      // TODO: Gradients transformed with the transformation matrix.
+      var angle = 0;
+      var focus = {x: 0, y: 0};
+
+      // additional offset
+      var shift = 0;
+      // scale factor for offset
+      var expansion = 1;
+
+      if (fillStyle.type_ == 'gradient') {
+        var x0 = fillStyle.x0_ / arcScaleX;
+        var y0 = fillStyle.y0_ / arcScaleY;
+        var x1 = fillStyle.x1_ / arcScaleX;
+        var y1 = fillStyle.y1_ / arcScaleY;
+        var p0 = ctx.getCoords_(x0, y0);
+        var p1 = ctx.getCoords_(x1, y1);
+        var dx = p1.x - p0.x;
+        var dy = p1.y - p0.y;
+        angle = Math.atan2(dx, dy) * 180 / Math.PI;
+
+        // The angle should be a non-negative number.
+        if (angle < 0) {
+          angle += 360;
+        }
+
+        // Very small angles produce an unexpected result because they are
+        // converted to a scientific notation string.
+        if (angle < 1e-6) {
+          angle = 0;
+        }
+      } else {
+        var p0 = ctx.getCoords_(fillStyle.x0_, fillStyle.y0_);
+        focus = {
+          x: (p0.x - min.x) / width,
+          y: (p0.y - min.y) / height
+        };
+
+        width  /= arcScaleX * Z;
+        height /= arcScaleY * Z;
+        var dimension = m.max(width, height);
+        shift = 2 * fillStyle.r0_ / dimension;
+        expansion = 2 * fillStyle.r1_ / dimension - shift;
+      }
+
+      // We need to sort the color stops in ascending order by offset,
+      // otherwise IE won't interpret it correctly.
+      var stops = fillStyle.colors_;
+      stops.sort(function(cs1, cs2) {
+        return cs1.offset - cs2.offset;
+      });
+
+      var length = stops.length;
+      var color1 = stops[0].color;
+      var color2 = stops[length - 1].color;
+      var opacity1 = stops[0].alpha * ctx.globalAlpha;
+      var opacity2 = stops[length - 1].alpha * ctx.globalAlpha;
+
+      var colors = [];
+      for (var i = 0; i < length; i++) {
+        var stop = stops[i];
+        colors.push(stop.offset * expansion + shift + ' ' + stop.color);
+      }
+
+      // When colors attribute is used, the meanings of opacity and o:opacity2
+      // are reversed.
+      lineStr.push('<g_vml_:fill type="', fillStyle.type_, '"',
+                   ' method="none" focus="100%"',
+                   ' color="', color1, '"',
+                   ' color2="', color2, '"',
+                   ' colors="', colors.join(','), '"',
+                   ' opacity="', opacity2, '"',
+                   ' g_o_:opacity2="', opacity1, '"',
+                   ' angle="', angle, '"',
+                   ' focusposition="', focus.x, ',', focus.y, '" />');
+    } else if (fillStyle instanceof CanvasPattern_) {
+      if (width && height) {
+        var deltaLeft = -min.x;
+        var deltaTop = -min.y;
+        lineStr.push('<g_vml_:fill',
+                     ' position="',
+                     deltaLeft / width * arcScaleX * arcScaleX, ',',
+                     deltaTop / height * arcScaleY * arcScaleY, '"',
+                     ' type="tile"',
+                     // TODO: Figure out the correct size to fit the scale.
+                     //' size="', w, 'px ', h, 'px"',
+                     ' src="', fillStyle.src_, '" />');
+       }
+    } else {
+      var a = processStyle(ctx.fillStyle);
+      var color = a.color;
+      var opacity = a.alpha * ctx.globalAlpha;
+      lineStr.push('<g_vml_:fill color="', color, '" opacity="', opacity,
+                   '" />');
+    }
+  }
+
+  contextPrototype.fill = function() {
+    this.stroke(true);
+  };
+
+  contextPrototype.closePath = function() {
+    this.currentPath_.push({type: 'close'});
+  };
+
+  /**
+   * @private
+   */
+  contextPrototype.getCoords_ = function(aX, aY) {
+    var m = this.m_;
+    return {
+      x: Z * (aX * m[0][0] + aY * m[1][0] + m[2][0]) - Z2,
+      y: Z * (aX * m[0][1] + aY * m[1][1] + m[2][1]) - Z2
+    };
+  };
+
+  contextPrototype.save = function() {
+    var o = {};
+    copyState(this, o);
+    this.aStack_.push(o);
+    this.mStack_.push(this.m_);
+    this.m_ = matrixMultiply(createMatrixIdentity(), this.m_);
+  };
+
+  contextPrototype.restore = function() {
+    if (this.aStack_.length) {
+      copyState(this.aStack_.pop(), this);
+      this.m_ = this.mStack_.pop();
+    }
+  };
+
+  function matrixIsFinite(m) {
+    return isFinite(m[0][0]) && isFinite(m[0][1]) &&
+        isFinite(m[1][0]) && isFinite(m[1][1]) &&
+        isFinite(m[2][0]) && isFinite(m[2][1]);
+  }
+
+  function setM(ctx, m, updateLineScale) {
+    if (!matrixIsFinite(m)) {
+      return;
+    }
+    ctx.m_ = m;
+
+    if (updateLineScale) {
+      // Get the line scale.
+      // Determinant of this.m_ means how much the area is enlarged by the
+      // transformation. So its square root can be used as a scale factor
+      // for width.
+      var det = m[0][0] * m[1][1] - m[0][1] * m[1][0];
+      ctx.lineScale_ = sqrt(abs(det));
+    }
+  }
+
+  contextPrototype.translate = function(aX, aY) {
+    var m1 = [
+      [1,  0,  0],
+      [0,  1,  0],
+      [aX, aY, 1]
+    ];
+
+    setM(this, matrixMultiply(m1, this.m_), false);
+  };
+
+  contextPrototype.rotate = function(aRot) {
+    var c = mc(aRot);
+    var s = ms(aRot);
+
+    var m1 = [
+      [c,  s, 0],
+      [-s, c, 0],
+      [0,  0, 1]
+    ];
+
+    setM(this, matrixMultiply(m1, this.m_), false);
+  };
+
+  contextPrototype.scale = function(aX, aY) {
+    this.arcScaleX_ *= aX;
+    this.arcScaleY_ *= aY;
+    var m1 = [
+      [aX, 0,  0],
+      [0,  aY, 0],
+      [0,  0,  1]
+    ];
+
+    setM(this, matrixMultiply(m1, this.m_), true);
+  };
+
+  contextPrototype.transform = function(m11, m12, m21, m22, dx, dy) {
+    var m1 = [
+      [m11, m12, 0],
+      [m21, m22, 0],
+      [dx,  dy,  1]
+    ];
+
+    setM(this, matrixMultiply(m1, this.m_), true);
+  };
+
+  contextPrototype.setTransform = function(m11, m12, m21, m22, dx, dy) {
+    var m = [
+      [m11, m12, 0],
+      [m21, m22, 0],
+      [dx,  dy,  1]
+    ];
+
+    setM(this, m, true);
+  };
+
+  /**
+   * The text drawing function.
+   * The maxWidth argument isn't taken in account, since no browser supports
+   * it yet.
+   */
+  contextPrototype.drawText_ = function(text, x, y, maxWidth, stroke) {
+    var m = this.m_,
+        delta = 1000,
+        left = 0,
+        right = delta,
+        offset = {x: 0, y: 0},
+        lineStr = [];
+
+    var fontStyle = getComputedStyle(processFontStyle(this.font),
+                                     this.element_);
+
+    var fontStyleString = buildStyle(fontStyle);
+
+    var elementStyle = this.element_.currentStyle;
+    var textAlign = this.textAlign.toLowerCase();
+    switch (textAlign) {
+      case 'left':
+      case 'center':
+      case 'right':
+        break;
+      case 'end':
+        textAlign = elementStyle.direction == 'ltr' ? 'right' : 'left';
+        break;
+      case 'start':
+        textAlign = elementStyle.direction == 'rtl' ? 'right' : 'left';
+        break;
+      default:
+        textAlign = 'left';
+    }
+
+    // 1.75 is an arbitrary number, as there is no info about the text baseline
+    switch (this.textBaseline) {
+      case 'hanging':
+      case 'top':
+        offset.y = fontStyle.size / 1.75;
+        break;
+      case 'middle':
+        break;
+      default:
+      case null:
+      case 'alphabetic':
+      case 'ideographic':
+      case 'bottom':
+        offset.y = -fontStyle.size / 2.25;
+        break;
+    }
+
+    switch(textAlign) {
+      case 'right':
+        left = delta;
+        right = 0.05;
+        break;
+      case 'center':
+        left = right = delta / 2;
+        break;
+    }
+
+    var d = this.getCoords_(x + offset.x, y + offset.y);
+
+    lineStr.push('<g_vml_:line from="', -left ,' 0" to="', right ,' 0.05" ',
+                 ' coordsize="100 100" coordorigin="0 0"',
+                 ' filled="', !stroke, '" stroked="', !!stroke,
+                 '" style="position:absolute;width:1px;height:1px;">');
+
+    if (stroke) {
+      appendStroke(this, lineStr);
+    } else {
+      // TODO: Fix the min and max params.
+      appendFill(this, lineStr, {x: -left, y: 0},
+                 {x: right, y: fontStyle.size});
+    }
+
+    var skewM = m[0][0].toFixed(3) + ',' + m[1][0].toFixed(3) + ',' +
+                m[0][1].toFixed(3) + ',' + m[1][1].toFixed(3) + ',0,0';
+
+    var skewOffset = mr(d.x / Z) + ',' + mr(d.y / Z);
+
+    lineStr.push('<g_vml_:skew on="t" matrix="', skewM ,'" ',
+                 ' offset="', skewOffset, '" origin="', left ,' 0" />',
+                 '<g_vml_:path textpathok="true" />',
+                 '<g_vml_:textpath on="true" string="',
+                 encodeHtmlAttribute(text),
+                 '" style="v-text-align:', textAlign,
+                 ';font:', encodeHtmlAttribute(fontStyleString),
+                 '" /></g_vml_:line>');
+
+    this.element_.insertAdjacentHTML('beforeEnd', lineStr.join(''));
+  };
+
+  contextPrototype.fillText = function(text, x, y, maxWidth) {
+    this.drawText_(text, x, y, maxWidth, false);
+  };
+
+  contextPrototype.strokeText = function(text, x, y, maxWidth) {
+    this.drawText_(text, x, y, maxWidth, true);
+  };
+
+  contextPrototype.measureText = function(text) {
+    if (!this.textMeasureEl_) {
+      var s = '<span style="position:absolute;' +
+          'top:-20000px;left:0;padding:0;margin:0;border:none;' +
+          'white-space:pre;"></span>';
+      this.element_.insertAdjacentHTML('beforeEnd', s);
+      this.textMeasureEl_ = this.element_.lastChild;
+    }
+    var doc = this.element_.ownerDocument;
+    this.textMeasureEl_.innerHTML = '';
+    this.textMeasureEl_.style.font = this.font;
+    // Don't use innerHTML or innerText because they allow markup/whitespace.
+    this.textMeasureEl_.appendChild(doc.createTextNode(text));
+    return {width: this.textMeasureEl_.offsetWidth};
+  };
+
+  /******** STUBS ********/
+  contextPrototype.clip = function() {
+    // TODO: Implement
+  };
+
+  contextPrototype.arcTo = function() {
+    // TODO: Implement
+  };
+
+  contextPrototype.createPattern = function(image, repetition) {
+    return new CanvasPattern_(image, repetition);
+  };
+
+  // Gradient / Pattern Stubs
+  function CanvasGradient_(aType) {
+    this.type_ = aType;
+    this.x0_ = 0;
+    this.y0_ = 0;
+    this.r0_ = 0;
+    this.x1_ = 0;
+    this.y1_ = 0;
+    this.r1_ = 0;
+    this.colors_ = [];
+  }
+
+  CanvasGradient_.prototype.addColorStop = function(aOffset, aColor) {
+    aColor = processStyle(aColor);
+    this.colors_.push({offset: aOffset,
+                       color: aColor.color,
+                       alpha: aColor.alpha});
+  };
+
+  function CanvasPattern_(image, repetition) {
+    assertImageIsValid(image);
+    switch (repetition) {
+      case 'repeat':
+      case null:
+      case '':
+        this.repetition_ = 'repeat';
+        break
+      case 'repeat-x':
+      case 'repeat-y':
+      case 'no-repeat':
+        this.repetition_ = repetition;
+        break;
+      default:
+        throwException('SYNTAX_ERR');
+    }
+
+    this.src_ = image.src;
+    this.width_ = image.width;
+    this.height_ = image.height;
+  }
+
+  function throwException(s) {
+    throw new DOMException_(s);
+  }
+
+  function assertImageIsValid(img) {
+    if (!img || img.nodeType != 1 || img.tagName != 'IMG') {
+      throwException('TYPE_MISMATCH_ERR');
+    }
+    if (img.readyState != 'complete') {
+      throwException('INVALID_STATE_ERR');
+    }
+  }
+
+  function DOMException_(s) {
+    this.code = this[s];
+    this.message = s +': DOM Exception ' + this.code;
+  }
+  var p = DOMException_.prototype = new Error;
+  p.INDEX_SIZE_ERR = 1;
+  p.DOMSTRING_SIZE_ERR = 2;
+  p.HIERARCHY_REQUEST_ERR = 3;
+  p.WRONG_DOCUMENT_ERR = 4;
+  p.INVALID_CHARACTER_ERR = 5;
+  p.NO_DATA_ALLOWED_ERR = 6;
+  p.NO_MODIFICATION_ALLOWED_ERR = 7;
+  p.NOT_FOUND_ERR = 8;
+  p.NOT_SUPPORTED_ERR = 9;
+  p.INUSE_ATTRIBUTE_ERR = 10;
+  p.INVALID_STATE_ERR = 11;
+  p.SYNTAX_ERR = 12;
+  p.INVALID_MODIFICATION_ERR = 13;
+  p.NAMESPACE_ERR = 14;
+  p.INVALID_ACCESS_ERR = 15;
+  p.VALIDATION_ERR = 16;
+  p.TYPE_MISMATCH_ERR = 17;
+
+  // set up externs
+  G_vmlCanvasManager = G_vmlCanvasManager_;
+  CanvasRenderingContext2D = CanvasRenderingContext2D_;
+  CanvasGradient = CanvasGradient_;
+  CanvasPattern = CanvasPattern_;
+  DOMException = DOMException_;
+})();
+
+} // if
diff --git a/web/js/jquery-1.4.2.min.js b/web/js/jquery-1.4.2.min.js
new file mode 100644 (file)
index 0000000..7c24308
--- /dev/null
@@ -0,0 +1,154 @@
+/*!
+ * jQuery JavaScript Library v1.4.2
+ * http://jquery.com/
+ *
+ * Copyright 2010, John Resig
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * Includes Sizzle.js
+ * http://sizzlejs.com/
+ * Copyright 2010, The Dojo Foundation
+ * Released under the MIT, BSD, and GPL Licenses.
+ *
+ * Date: Sat Feb 13 22:33:48 2010 -0500
+ */
+(function(A,w){function ma(){if(!c.isReady){try{s.documentElement.doScroll("left")}catch(a){setTimeout(ma,1);return}c.ready()}}function Qa(a,b){b.src?c.ajax({url:b.src,async:false,dataType:"script"}):c.globalEval(b.text||b.textContent||b.innerHTML||"");b.parentNode&&b.parentNode.removeChild(b)}function X(a,b,d,f,e,j){var i=a.length;if(typeof b==="object"){for(var o in b)X(a,o,b[o],f,e,d);return a}if(d!==w){f=!j&&f&&c.isFunction(d);for(o=0;o<i;o++)e(a[o],b,f?d.call(a[o],o,e(a[o],b)):d,j);return a}return i?
+e(a[0],b):w}function J(){return(new Date).getTime()}function Y(){return false}function Z(){return true}function na(a,b,d){d[0].type=a;return c.event.handle.apply(b,d)}function oa(a){var b,d=[],f=[],e=arguments,j,i,o,k,n,r;i=c.data(this,"events");if(!(a.liveFired===this||!i||!i.live||a.button&&a.type==="click")){a.liveFired=this;var u=i.live.slice(0);for(k=0;k<u.length;k++){i=u[k];i.origType.replace(O,"")===a.type?f.push(i.selector):u.splice(k--,1)}j=c(a.target).closest(f,a.currentTarget);n=0;for(r=
+j.length;n<r;n++)for(k=0;k<u.length;k++){i=u[k];if(j[n].selector===i.selector){o=j[n].elem;f=null;if(i.preType==="mouseenter"||i.preType==="mouseleave")f=c(a.relatedTarget).closest(i.selector)[0];if(!f||f!==o)d.push({elem:o,handleObj:i})}}n=0;for(r=d.length;n<r;n++){j=d[n];a.currentTarget=j.elem;a.data=j.handleObj.data;a.handleObj=j.handleObj;if(j.handleObj.origHandler.apply(j.elem,e)===false){b=false;break}}return b}}function pa(a,b){return"live."+(a&&a!=="*"?a+".":"")+b.replace(/\./g,"`").replace(/ /g,
+"&")}function qa(a){return!a||!a.parentNode||a.parentNode.nodeType===11}function ra(a,b){var d=0;b.each(function(){if(this.nodeName===(a[d]&&a[d].nodeName)){var f=c.data(a[d++]),e=c.data(this,f);if(f=f&&f.events){delete e.handle;e.events={};for(var j in f)for(var i in f[j])c.event.add(this,j,f[j][i],f[j][i].data)}}})}function sa(a,b,d){var f,e,j;b=b&&b[0]?b[0].ownerDocument||b[0]:s;if(a.length===1&&typeof a[0]==="string"&&a[0].length<512&&b===s&&!ta.test(a[0])&&(c.support.checkClone||!ua.test(a[0]))){e=
+true;if(j=c.fragments[a[0]])if(j!==1)f=j}if(!f){f=b.createDocumentFragment();c.clean(a,b,f,d)}if(e)c.fragments[a[0]]=j?f:1;return{fragment:f,cacheable:e}}function K(a,b){var d={};c.each(va.concat.apply([],va.slice(0,b)),function(){d[this]=a});return d}function wa(a){return"scrollTo"in a&&a.document?a:a.nodeType===9?a.defaultView||a.parentWindow:false}var c=function(a,b){return new c.fn.init(a,b)},Ra=A.jQuery,Sa=A.$,s=A.document,T,Ta=/^[^<]*(<[\w\W]+>)[^>]*$|^#([\w-]+)$/,Ua=/^.[^:#\[\.,]*$/,Va=/\S/,
+Wa=/^(\s|\u00A0)+|(\s|\u00A0)+$/g,Xa=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,P=navigator.userAgent,xa=false,Q=[],L,$=Object.prototype.toString,aa=Object.prototype.hasOwnProperty,ba=Array.prototype.push,R=Array.prototype.slice,ya=Array.prototype.indexOf;c.fn=c.prototype={init:function(a,b){var d,f;if(!a)return this;if(a.nodeType){this.context=this[0]=a;this.length=1;return this}if(a==="body"&&!b){this.context=s;this[0]=s.body;this.selector="body";this.length=1;return this}if(typeof a==="string")if((d=Ta.exec(a))&&
+(d[1]||!b))if(d[1]){f=b?b.ownerDocument||b:s;if(a=Xa.exec(a))if(c.isPlainObject(b)){a=[s.createElement(a[1])];c.fn.attr.call(a,b,true)}else a=[f.createElement(a[1])];else{a=sa([d[1]],[f]);a=(a.cacheable?a.fragment.cloneNode(true):a.fragment).childNodes}return c.merge(this,a)}else{if(b=s.getElementById(d[2])){if(b.id!==d[2])return T.find(a);this.length=1;this[0]=b}this.context=s;this.selector=a;return this}else if(!b&&/^\w+$/.test(a)){this.selector=a;this.context=s;a=s.getElementsByTagName(a);return c.merge(this,
+a)}else return!b||b.jquery?(b||T).find(a):c(b).find(a);else if(c.isFunction(a))return T.ready(a);if(a.selector!==w){this.selector=a.selector;this.context=a.context}return c.makeArray(a,this)},selector:"",jquery:"1.4.2",length:0,size:function(){return this.length},toArray:function(){return R.call(this,0)},get:function(a){return a==null?this.toArray():a<0?this.slice(a)[0]:this[a]},pushStack:function(a,b,d){var f=c();c.isArray(a)?ba.apply(f,a):c.merge(f,a);f.prevObject=this;f.context=this.context;if(b===
+"find")f.selector=this.selector+(this.selector?" ":"")+d;else if(b)f.selector=this.selector+"."+b+"("+d+")";return f},each:function(a,b){return c.each(this,a,b)},ready:function(a){c.bindReady();if(c.isReady)a.call(s,c);else Q&&Q.push(a);return this},eq:function(a){return a===-1?this.slice(a):this.slice(a,+a+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(R.apply(this,arguments),"slice",R.call(arguments).join(","))},map:function(a){return this.pushStack(c.map(this,
+function(b,d){return a.call(b,d,b)}))},end:function(){return this.prevObject||c(null)},push:ba,sort:[].sort,splice:[].splice};c.fn.init.prototype=c.fn;c.extend=c.fn.extend=function(){var a=arguments[0]||{},b=1,d=arguments.length,f=false,e,j,i,o;if(typeof a==="boolean"){f=a;a=arguments[1]||{};b=2}if(typeof a!=="object"&&!c.isFunction(a))a={};if(d===b){a=this;--b}for(;b<d;b++)if((e=arguments[b])!=null)for(j in e){i=a[j];o=e[j];if(a!==o)if(f&&o&&(c.isPlainObject(o)||c.isArray(o))){i=i&&(c.isPlainObject(i)||
+c.isArray(i))?i:c.isArray(o)?[]:{};a[j]=c.extend(f,i,o)}else if(o!==w)a[j]=o}return a};c.extend({noConflict:function(a){A.$=Sa;if(a)A.jQuery=Ra;return c},isReady:false,ready:function(){if(!c.isReady){if(!s.body)return setTimeout(c.ready,13);c.isReady=true;if(Q){for(var a,b=0;a=Q[b++];)a.call(s,c);Q=null}c.fn.triggerHandler&&c(s).triggerHandler("ready")}},bindReady:function(){if(!xa){xa=true;if(s.readyState==="complete")return c.ready();if(s.addEventListener){s.addEventListener("DOMContentLoaded",
+L,false);A.addEventListener("load",c.ready,false)}else if(s.attachEvent){s.attachEvent("onreadystatechange",L);A.attachEvent("onload",c.ready);var a=false;try{a=A.frameElement==null}catch(b){}s.documentElement.doScroll&&a&&ma()}}},isFunction:function(a){return $.call(a)==="[object Function]"},isArray:function(a){return $.call(a)==="[object Array]"},isPlainObject:function(a){if(!a||$.call(a)!=="[object Object]"||a.nodeType||a.setInterval)return false;if(a.constructor&&!aa.call(a,"constructor")&&!aa.call(a.constructor.prototype,
+"isPrototypeOf"))return false;var b;for(b in a);return b===w||aa.call(a,b)},isEmptyObject:function(a){for(var b in a)return false;return true},error:function(a){throw a;},parseJSON:function(a){if(typeof a!=="string"||!a)return null;a=c.trim(a);if(/^[\],:{}\s]*$/.test(a.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,"@").replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,"]").replace(/(?:^|:|,)(?:\s*\[)+/g,"")))return A.JSON&&A.JSON.parse?A.JSON.parse(a):(new Function("return "+
+a))();else c.error("Invalid JSON: "+a)},noop:function(){},globalEval:function(a){if(a&&Va.test(a)){var b=s.getElementsByTagName("head")[0]||s.documentElement,d=s.createElement("script");d.type="text/javascript";if(c.support.scriptEval)d.appendChild(s.createTextNode(a));else d.text=a;b.insertBefore(d,b.firstChild);b.removeChild(d)}},nodeName:function(a,b){return a.nodeName&&a.nodeName.toUpperCase()===b.toUpperCase()},each:function(a,b,d){var f,e=0,j=a.length,i=j===w||c.isFunction(a);if(d)if(i)for(f in a){if(b.apply(a[f],
+d)===false)break}else for(;e<j;){if(b.apply(a[e++],d)===false)break}else if(i)for(f in a){if(b.call(a[f],f,a[f])===false)break}else for(d=a[0];e<j&&b.call(d,e,d)!==false;d=a[++e]);return a},trim:function(a){return(a||"").replace(Wa,"")},makeArray:function(a,b){b=b||[];if(a!=null)a.length==null||typeof a==="string"||c.isFunction(a)||typeof a!=="function"&&a.setInterval?ba.call(b,a):c.merge(b,a);return b},inArray:function(a,b){if(b.indexOf)return b.indexOf(a);for(var d=0,f=b.length;d<f;d++)if(b[d]===
+a)return d;return-1},merge:function(a,b){var d=a.length,f=0;if(typeof b.length==="number")for(var e=b.length;f<e;f++)a[d++]=b[f];else for(;b[f]!==w;)a[d++]=b[f++];a.length=d;return a},grep:function(a,b,d){for(var f=[],e=0,j=a.length;e<j;e++)!d!==!b(a[e],e)&&f.push(a[e]);return f},map:function(a,b,d){for(var f=[],e,j=0,i=a.length;j<i;j++){e=b(a[j],j,d);if(e!=null)f[f.length]=e}return f.concat.apply([],f)},guid:1,proxy:function(a,b,d){if(arguments.length===2)if(typeof b==="string"){d=a;a=d[b];b=w}else if(b&&
+!c.isFunction(b)){d=b;b=w}if(!b&&a)b=function(){return a.apply(d||this,arguments)};if(a)b.guid=a.guid=a.guid||b.guid||c.guid++;return b},uaMatch:function(a){a=a.toLowerCase();a=/(webkit)[ \/]([\w.]+)/.exec(a)||/(opera)(?:.*version)?[ \/]([\w.]+)/.exec(a)||/(msie) ([\w.]+)/.exec(a)||!/compatible/.test(a)&&/(mozilla)(?:.*? rv:([\w.]+))?/.exec(a)||[];return{browser:a[1]||"",version:a[2]||"0"}},browser:{}});P=c.uaMatch(P);if(P.browser){c.browser[P.browser]=true;c.browser.version=P.version}if(c.browser.webkit)c.browser.safari=
+true;if(ya)c.inArray=function(a,b){return ya.call(b,a)};T=c(s);if(s.addEventListener)L=function(){s.removeEventListener("DOMContentLoaded",L,false);c.ready()};else if(s.attachEvent)L=function(){if(s.readyState==="complete"){s.detachEvent("onreadystatechange",L);c.ready()}};(function(){c.support={};var a=s.documentElement,b=s.createElement("script"),d=s.createElement("div"),f="script"+J();d.style.display="none";d.innerHTML="   <link/><table></table><a href='/a' style='color:red;float:left;opacity:.55;'>a</a><input type='checkbox'/>";
+var e=d.getElementsByTagName("*"),j=d.getElementsByTagName("a")[0];if(!(!e||!e.length||!j)){c.support={leadingWhitespace:d.firstChild.nodeType===3,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/red/.test(j.getAttribute("style")),hrefNormalized:j.getAttribute("href")==="/a",opacity:/^0.55$/.test(j.style.opacity),cssFloat:!!j.style.cssFloat,checkOn:d.getElementsByTagName("input")[0].value==="on",optSelected:s.createElement("select").appendChild(s.createElement("option")).selected,
+parentNode:d.removeChild(d.appendChild(s.createElement("div"))).parentNode===null,deleteExpando:true,checkClone:false,scriptEval:false,noCloneEvent:true,boxModel:null};b.type="text/javascript";try{b.appendChild(s.createTextNode("window."+f+"=1;"))}catch(i){}a.insertBefore(b,a.firstChild);if(A[f]){c.support.scriptEval=true;delete A[f]}try{delete b.test}catch(o){c.support.deleteExpando=false}a.removeChild(b);if(d.attachEvent&&d.fireEvent){d.attachEvent("onclick",function k(){c.support.noCloneEvent=
+false;d.detachEvent("onclick",k)});d.cloneNode(true).fireEvent("onclick")}d=s.createElement("div");d.innerHTML="<input type='radio' name='radiotest' checked='checked'/>";a=s.createDocumentFragment();a.appendChild(d.firstChild);c.support.checkClone=a.cloneNode(true).cloneNode(true).lastChild.checked;c(function(){var k=s.createElement("div");k.style.width=k.style.paddingLeft="1px";s.body.appendChild(k);c.boxModel=c.support.boxModel=k.offsetWidth===2;s.body.removeChild(k).style.display="none"});a=function(k){var n=
+s.createElement("div");k="on"+k;var r=k in n;if(!r){n.setAttribute(k,"return;");r=typeof n[k]==="function"}return r};c.support.submitBubbles=a("submit");c.support.changeBubbles=a("change");a=b=d=e=j=null}})();c.props={"for":"htmlFor","class":"className",readonly:"readOnly",maxlength:"maxLength",cellspacing:"cellSpacing",rowspan:"rowSpan",colspan:"colSpan",tabindex:"tabIndex",usemap:"useMap",frameborder:"frameBorder"};var G="jQuery"+J(),Ya=0,za={};c.extend({cache:{},expando:G,noData:{embed:true,object:true,
+applet:true},data:function(a,b,d){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var f=a[G],e=c.cache;if(!f&&typeof b==="string"&&d===w)return null;f||(f=++Ya);if(typeof b==="object"){a[G]=f;e[f]=c.extend(true,{},b)}else if(!e[f]){a[G]=f;e[f]={}}a=e[f];if(d!==w)a[b]=d;return typeof b==="string"?a[b]:a}},removeData:function(a,b){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var d=a[G],f=c.cache,e=f[d];if(b){if(e){delete e[b];c.isEmptyObject(e)&&c.removeData(a)}}else{if(c.support.deleteExpando)delete a[c.expando];
+else a.removeAttribute&&a.removeAttribute(c.expando);delete f[d]}}}});c.fn.extend({data:function(a,b){if(typeof a==="undefined"&&this.length)return c.data(this[0]);else if(typeof a==="object")return this.each(function(){c.data(this,a)});var d=a.split(".");d[1]=d[1]?"."+d[1]:"";if(b===w){var f=this.triggerHandler("getData"+d[1]+"!",[d[0]]);if(f===w&&this.length)f=c.data(this[0],a);return f===w&&d[1]?this.data(d[0]):f}else return this.trigger("setData"+d[1]+"!",[d[0],b]).each(function(){c.data(this,
+a,b)})},removeData:function(a){return this.each(function(){c.removeData(this,a)})}});c.extend({queue:function(a,b,d){if(a){b=(b||"fx")+"queue";var f=c.data(a,b);if(!d)return f||[];if(!f||c.isArray(d))f=c.data(a,b,c.makeArray(d));else f.push(d);return f}},dequeue:function(a,b){b=b||"fx";var d=c.queue(a,b),f=d.shift();if(f==="inprogress")f=d.shift();if(f){b==="fx"&&d.unshift("inprogress");f.call(a,function(){c.dequeue(a,b)})}}});c.fn.extend({queue:function(a,b){if(typeof a!=="string"){b=a;a="fx"}if(b===
+w)return c.queue(this[0],a);return this.each(function(){var d=c.queue(this,a,b);a==="fx"&&d[0]!=="inprogress"&&c.dequeue(this,a)})},dequeue:function(a){return this.each(function(){c.dequeue(this,a)})},delay:function(a,b){a=c.fx?c.fx.speeds[a]||a:a;b=b||"fx";return this.queue(b,function(){var d=this;setTimeout(function(){c.dequeue(d,b)},a)})},clearQueue:function(a){return this.queue(a||"fx",[])}});var Aa=/[\n\t]/g,ca=/\s+/,Za=/\r/g,$a=/href|src|style/,ab=/(button|input)/i,bb=/(button|input|object|select|textarea)/i,
+cb=/^(a|area)$/i,Ba=/radio|checkbox/;c.fn.extend({attr:function(a,b){return X(this,a,b,true,c.attr)},removeAttr:function(a){return this.each(function(){c.attr(this,a,"");this.nodeType===1&&this.removeAttribute(a)})},addClass:function(a){if(c.isFunction(a))return this.each(function(n){var r=c(this);r.addClass(a.call(this,n,r.attr("class")))});if(a&&typeof a==="string")for(var b=(a||"").split(ca),d=0,f=this.length;d<f;d++){var e=this[d];if(e.nodeType===1)if(e.className){for(var j=" "+e.className+" ",
+i=e.className,o=0,k=b.length;o<k;o++)if(j.indexOf(" "+b[o]+" ")<0)i+=" "+b[o];e.className=c.trim(i)}else e.className=a}return this},removeClass:function(a){if(c.isFunction(a))return this.each(function(k){var n=c(this);n.removeClass(a.call(this,k,n.attr("class")))});if(a&&typeof a==="string"||a===w)for(var b=(a||"").split(ca),d=0,f=this.length;d<f;d++){var e=this[d];if(e.nodeType===1&&e.className)if(a){for(var j=(" "+e.className+" ").replace(Aa," "),i=0,o=b.length;i<o;i++)j=j.replace(" "+b[i]+" ",
+" ");e.className=c.trim(j)}else e.className=""}return this},toggleClass:function(a,b){var d=typeof a,f=typeof b==="boolean";if(c.isFunction(a))return this.each(function(e){var j=c(this);j.toggleClass(a.call(this,e,j.attr("class"),b),b)});return this.each(function(){if(d==="string")for(var e,j=0,i=c(this),o=b,k=a.split(ca);e=k[j++];){o=f?o:!i.hasClass(e);i[o?"addClass":"removeClass"](e)}else if(d==="undefined"||d==="boolean"){this.className&&c.data(this,"__className__",this.className);this.className=
+this.className||a===false?"":c.data(this,"__className__")||""}})},hasClass:function(a){a=" "+a+" ";for(var b=0,d=this.length;b<d;b++)if((" "+this[b].className+" ").replace(Aa," ").indexOf(a)>-1)return true;return false},val:function(a){if(a===w){var b=this[0];if(b){if(c.nodeName(b,"option"))return(b.attributes.value||{}).specified?b.value:b.text;if(c.nodeName(b,"select")){var d=b.selectedIndex,f=[],e=b.options;b=b.type==="select-one";if(d<0)return null;var j=b?d:0;for(d=b?d+1:e.length;j<d;j++){var i=
+e[j];if(i.selected){a=c(i).val();if(b)return a;f.push(a)}}return f}if(Ba.test(b.type)&&!c.support.checkOn)return b.getAttribute("value")===null?"on":b.value;return(b.value||"").replace(Za,"")}return w}var o=c.isFunction(a);return this.each(function(k){var n=c(this),r=a;if(this.nodeType===1){if(o)r=a.call(this,k,n.val());if(typeof r==="number")r+="";if(c.isArray(r)&&Ba.test(this.type))this.checked=c.inArray(n.val(),r)>=0;else if(c.nodeName(this,"select")){var u=c.makeArray(r);c("option",this).each(function(){this.selected=
+c.inArray(c(this).val(),u)>=0});if(!u.length)this.selectedIndex=-1}else this.value=r}})}});c.extend({attrFn:{val:true,css:true,html:true,text:true,data:true,width:true,height:true,offset:true},attr:function(a,b,d,f){if(!a||a.nodeType===3||a.nodeType===8)return w;if(f&&b in c.attrFn)return c(a)[b](d);f=a.nodeType!==1||!c.isXMLDoc(a);var e=d!==w;b=f&&c.props[b]||b;if(a.nodeType===1){var j=$a.test(b);if(b in a&&f&&!j){if(e){b==="type"&&ab.test(a.nodeName)&&a.parentNode&&c.error("type property can't be changed");
+a[b]=d}if(c.nodeName(a,"form")&&a.getAttributeNode(b))return a.getAttributeNode(b).nodeValue;if(b==="tabIndex")return(b=a.getAttributeNode("tabIndex"))&&b.specified?b.value:bb.test(a.nodeName)||cb.test(a.nodeName)&&a.href?0:w;return a[b]}if(!c.support.style&&f&&b==="style"){if(e)a.style.cssText=""+d;return a.style.cssText}e&&a.setAttribute(b,""+d);a=!c.support.hrefNormalized&&f&&j?a.getAttribute(b,2):a.getAttribute(b);return a===null?w:a}return c.style(a,b,d)}});var O=/\.(.*)$/,db=function(a){return a.replace(/[^\w\s\.\|`]/g,
+function(b){return"\\"+b})};c.event={add:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){if(a.setInterval&&a!==A&&!a.frameElement)a=A;var e,j;if(d.handler){e=d;d=e.handler}if(!d.guid)d.guid=c.guid++;if(j=c.data(a)){var i=j.events=j.events||{},o=j.handle;if(!o)j.handle=o=function(){return typeof c!=="undefined"&&!c.event.triggered?c.event.handle.apply(o.elem,arguments):w};o.elem=a;b=b.split(" ");for(var k,n=0,r;k=b[n++];){j=e?c.extend({},e):{handler:d,data:f};if(k.indexOf(".")>-1){r=k.split(".");
+k=r.shift();j.namespace=r.slice(0).sort().join(".")}else{r=[];j.namespace=""}j.type=k;j.guid=d.guid;var u=i[k],z=c.event.special[k]||{};if(!u){u=i[k]=[];if(!z.setup||z.setup.call(a,f,r,o)===false)if(a.addEventListener)a.addEventListener(k,o,false);else a.attachEvent&&a.attachEvent("on"+k,o)}if(z.add){z.add.call(a,j);if(!j.handler.guid)j.handler.guid=d.guid}u.push(j);c.event.global[k]=true}a=null}}},global:{},remove:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){var e,j=0,i,o,k,n,r,u,z=c.data(a),
+C=z&&z.events;if(z&&C){if(b&&b.type){d=b.handler;b=b.type}if(!b||typeof b==="string"&&b.charAt(0)==="."){b=b||"";for(e in C)c.event.remove(a,e+b)}else{for(b=b.split(" ");e=b[j++];){n=e;i=e.indexOf(".")<0;o=[];if(!i){o=e.split(".");e=o.shift();k=new RegExp("(^|\\.)"+c.map(o.slice(0).sort(),db).join("\\.(?:.*\\.)?")+"(\\.|$)")}if(r=C[e])if(d){n=c.event.special[e]||{};for(B=f||0;B<r.length;B++){u=r[B];if(d.guid===u.guid){if(i||k.test(u.namespace)){f==null&&r.splice(B--,1);n.remove&&n.remove.call(a,u)}if(f!=
+null)break}}if(r.length===0||f!=null&&r.length===1){if(!n.teardown||n.teardown.call(a,o)===false)Ca(a,e,z.handle);delete C[e]}}else for(var B=0;B<r.length;B++){u=r[B];if(i||k.test(u.namespace)){c.event.remove(a,n,u.handler,B);r.splice(B--,1)}}}if(c.isEmptyObject(C)){if(b=z.handle)b.elem=null;delete z.events;delete z.handle;c.isEmptyObject(z)&&c.removeData(a)}}}}},trigger:function(a,b,d,f){var e=a.type||a;if(!f){a=typeof a==="object"?a[G]?a:c.extend(c.Event(e),a):c.Event(e);if(e.indexOf("!")>=0){a.type=
+e=e.slice(0,-1);a.exclusive=true}if(!d){a.stopPropagation();c.event.global[e]&&c.each(c.cache,function(){this.events&&this.events[e]&&c.event.trigger(a,b,this.handle.elem)})}if(!d||d.nodeType===3||d.nodeType===8)return w;a.result=w;a.target=d;b=c.makeArray(b);b.unshift(a)}a.currentTarget=d;(f=c.data(d,"handle"))&&f.apply(d,b);f=d.parentNode||d.ownerDocument;try{if(!(d&&d.nodeName&&c.noData[d.nodeName.toLowerCase()]))if(d["on"+e]&&d["on"+e].apply(d,b)===false)a.result=false}catch(j){}if(!a.isPropagationStopped()&&
+f)c.event.trigger(a,b,f,true);else if(!a.isDefaultPrevented()){f=a.target;var i,o=c.nodeName(f,"a")&&e==="click",k=c.event.special[e]||{};if((!k._default||k._default.call(d,a)===false)&&!o&&!(f&&f.nodeName&&c.noData[f.nodeName.toLowerCase()])){try{if(f[e]){if(i=f["on"+e])f["on"+e]=null;c.event.triggered=true;f[e]()}}catch(n){}if(i)f["on"+e]=i;c.event.triggered=false}}},handle:function(a){var b,d,f,e;a=arguments[0]=c.event.fix(a||A.event);a.currentTarget=this;b=a.type.indexOf(".")<0&&!a.exclusive;
+if(!b){d=a.type.split(".");a.type=d.shift();f=new RegExp("(^|\\.)"+d.slice(0).sort().join("\\.(?:.*\\.)?")+"(\\.|$)")}e=c.data(this,"events");d=e[a.type];if(e&&d){d=d.slice(0);e=0;for(var j=d.length;e<j;e++){var i=d[e];if(b||f.test(i.namespace)){a.handler=i.handler;a.data=i.data;a.handleObj=i;i=i.handler.apply(this,arguments);if(i!==w){a.result=i;if(i===false){a.preventDefault();a.stopPropagation()}}if(a.isImmediatePropagationStopped())break}}}return a.result},props:"altKey attrChange attrName bubbles button cancelable charCode clientX clientY ctrlKey currentTarget data detail eventPhase fromElement handler keyCode layerX layerY metaKey newValue offsetX offsetY originalTarget pageX pageY prevValue relatedNode relatedTarget screenX screenY shiftKey srcElement target toElement view wheelDelta which".split(" "),
+fix:function(a){if(a[G])return a;var b=a;a=c.Event(b);for(var d=this.props.length,f;d;){f=this.props[--d];a[f]=b[f]}if(!a.target)a.target=a.srcElement||s;if(a.target.nodeType===3)a.target=a.target.parentNode;if(!a.relatedTarget&&a.fromElement)a.relatedTarget=a.fromElement===a.target?a.toElement:a.fromElement;if(a.pageX==null&&a.clientX!=null){b=s.documentElement;d=s.body;a.pageX=a.clientX+(b&&b.scrollLeft||d&&d.scrollLeft||0)-(b&&b.clientLeft||d&&d.clientLeft||0);a.pageY=a.clientY+(b&&b.scrollTop||
+d&&d.scrollTop||0)-(b&&b.clientTop||d&&d.clientTop||0)}if(!a.which&&(a.charCode||a.charCode===0?a.charCode:a.keyCode))a.which=a.charCode||a.keyCode;if(!a.metaKey&&a.ctrlKey)a.metaKey=a.ctrlKey;if(!a.which&&a.button!==w)a.which=a.button&1?1:a.button&2?3:a.button&4?2:0;return a},guid:1E8,proxy:c.proxy,special:{ready:{setup:c.bindReady,teardown:c.noop},live:{add:function(a){c.event.add(this,a.origType,c.extend({},a,{handler:oa}))},remove:function(a){var b=true,d=a.origType.replace(O,"");c.each(c.data(this,
+"events").live||[],function(){if(d===this.origType.replace(O,""))return b=false});b&&c.event.remove(this,a.origType,oa)}},beforeunload:{setup:function(a,b,d){if(this.setInterval)this.onbeforeunload=d;return false},teardown:function(a,b){if(this.onbeforeunload===b)this.onbeforeunload=null}}}};var Ca=s.removeEventListener?function(a,b,d){a.removeEventListener(b,d,false)}:function(a,b,d){a.detachEvent("on"+b,d)};c.Event=function(a){if(!this.preventDefault)return new c.Event(a);if(a&&a.type){this.originalEvent=
+a;this.type=a.type}else this.type=a;this.timeStamp=J();this[G]=true};c.Event.prototype={preventDefault:function(){this.isDefaultPrevented=Z;var a=this.originalEvent;if(a){a.preventDefault&&a.preventDefault();a.returnValue=false}},stopPropagation:function(){this.isPropagationStopped=Z;var a=this.originalEvent;if(a){a.stopPropagation&&a.stopPropagation();a.cancelBubble=true}},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=Z;this.stopPropagation()},isDefaultPrevented:Y,isPropagationStopped:Y,
+isImmediatePropagationStopped:Y};var Da=function(a){var b=a.relatedTarget;try{for(;b&&b!==this;)b=b.parentNode;if(b!==this){a.type=a.data;c.event.handle.apply(this,arguments)}}catch(d){}},Ea=function(a){a.type=a.data;c.event.handle.apply(this,arguments)};c.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(a,b){c.event.special[a]={setup:function(d){c.event.add(this,b,d&&d.selector?Ea:Da,a)},teardown:function(d){c.event.remove(this,b,d&&d.selector?Ea:Da)}}});if(!c.support.submitBubbles)c.event.special.submit=
+{setup:function(){if(this.nodeName.toLowerCase()!=="form"){c.event.add(this,"click.specialSubmit",function(a){var b=a.target,d=b.type;if((d==="submit"||d==="image")&&c(b).closest("form").length)return na("submit",this,arguments)});c.event.add(this,"keypress.specialSubmit",function(a){var b=a.target,d=b.type;if((d==="text"||d==="password")&&c(b).closest("form").length&&a.keyCode===13)return na("submit",this,arguments)})}else return false},teardown:function(){c.event.remove(this,".specialSubmit")}};
+if(!c.support.changeBubbles){var da=/textarea|input|select/i,ea,Fa=function(a){var b=a.type,d=a.value;if(b==="radio"||b==="checkbox")d=a.checked;else if(b==="select-multiple")d=a.selectedIndex>-1?c.map(a.options,function(f){return f.selected}).join("-"):"";else if(a.nodeName.toLowerCase()==="select")d=a.selectedIndex;return d},fa=function(a,b){var d=a.target,f,e;if(!(!da.test(d.nodeName)||d.readOnly)){f=c.data(d,"_change_data");e=Fa(d);if(a.type!=="focusout"||d.type!=="radio")c.data(d,"_change_data",
+e);if(!(f===w||e===f))if(f!=null||e){a.type="change";return c.event.trigger(a,b,d)}}};c.event.special.change={filters:{focusout:fa,click:function(a){var b=a.target,d=b.type;if(d==="radio"||d==="checkbox"||b.nodeName.toLowerCase()==="select")return fa.call(this,a)},keydown:function(a){var b=a.target,d=b.type;if(a.keyCode===13&&b.nodeName.toLowerCase()!=="textarea"||a.keyCode===32&&(d==="checkbox"||d==="radio")||d==="select-multiple")return fa.call(this,a)},beforeactivate:function(a){a=a.target;c.data(a,
+"_change_data",Fa(a))}},setup:function(){if(this.type==="file")return false;for(var a in ea)c.event.add(this,a+".specialChange",ea[a]);return da.test(this.nodeName)},teardown:function(){c.event.remove(this,".specialChange");return da.test(this.nodeName)}};ea=c.event.special.change.filters}s.addEventListener&&c.each({focus:"focusin",blur:"focusout"},function(a,b){function d(f){f=c.event.fix(f);f.type=b;return c.event.handle.call(this,f)}c.event.special[b]={setup:function(){this.addEventListener(a,
+d,true)},teardown:function(){this.removeEventListener(a,d,true)}}});c.each(["bind","one"],function(a,b){c.fn[b]=function(d,f,e){if(typeof d==="object"){for(var j in d)this[b](j,f,d[j],e);return this}if(c.isFunction(f)){e=f;f=w}var i=b==="one"?c.proxy(e,function(k){c(this).unbind(k,i);return e.apply(this,arguments)}):e;if(d==="unload"&&b!=="one")this.one(d,f,e);else{j=0;for(var o=this.length;j<o;j++)c.event.add(this[j],d,i,f)}return this}});c.fn.extend({unbind:function(a,b){if(typeof a==="object"&&
+!a.preventDefault)for(var d in a)this.unbind(d,a[d]);else{d=0;for(var f=this.length;d<f;d++)c.event.remove(this[d],a,b)}return this},delegate:function(a,b,d,f){return this.live(b,d,f,a)},undelegate:function(a,b,d){return arguments.length===0?this.unbind("live"):this.die(b,null,d,a)},trigger:function(a,b){return this.each(function(){c.event.trigger(a,b,this)})},triggerHandler:function(a,b){if(this[0]){a=c.Event(a);a.preventDefault();a.stopPropagation();c.event.trigger(a,b,this[0]);return a.result}},
+toggle:function(a){for(var b=arguments,d=1;d<b.length;)c.proxy(a,b[d++]);return this.click(c.proxy(a,function(f){var e=(c.data(this,"lastToggle"+a.guid)||0)%d;c.data(this,"lastToggle"+a.guid,e+1);f.preventDefault();return b[e].apply(this,arguments)||false}))},hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}});var Ga={focus:"focusin",blur:"focusout",mouseenter:"mouseover",mouseleave:"mouseout"};c.each(["live","die"],function(a,b){c.fn[b]=function(d,f,e,j){var i,o=0,k,n,r=j||this.selector,
+u=j?this:c(this.context);if(c.isFunction(f)){e=f;f=w}for(d=(d||"").split(" ");(i=d[o++])!=null;){j=O.exec(i);k="";if(j){k=j[0];i=i.replace(O,"")}if(i==="hover")d.push("mouseenter"+k,"mouseleave"+k);else{n=i;if(i==="focus"||i==="blur"){d.push(Ga[i]+k);i+=k}else i=(Ga[i]||i)+k;b==="live"?u.each(function(){c.event.add(this,pa(i,r),{data:f,selector:r,handler:e,origType:i,origHandler:e,preType:n})}):u.unbind(pa(i,r),e)}}return this}});c.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error".split(" "),
+function(a,b){c.fn[b]=function(d){return d?this.bind(b,d):this.trigger(b)};if(c.attrFn)c.attrFn[b]=true});A.attachEvent&&!A.addEventListener&&A.attachEvent("onunload",function(){for(var a in c.cache)if(c.cache[a].handle)try{c.event.remove(c.cache[a].handle.elem)}catch(b){}});(function(){function a(g){for(var h="",l,m=0;g[m];m++){l=g[m];if(l.nodeType===3||l.nodeType===4)h+=l.nodeValue;else if(l.nodeType!==8)h+=a(l.childNodes)}return h}function b(g,h,l,m,q,p){q=0;for(var v=m.length;q<v;q++){var t=m[q];
+if(t){t=t[g];for(var y=false;t;){if(t.sizcache===l){y=m[t.sizset];break}if(t.nodeType===1&&!p){t.sizcache=l;t.sizset=q}if(t.nodeName.toLowerCase()===h){y=t;break}t=t[g]}m[q]=y}}}function d(g,h,l,m,q,p){q=0;for(var v=m.length;q<v;q++){var t=m[q];if(t){t=t[g];for(var y=false;t;){if(t.sizcache===l){y=m[t.sizset];break}if(t.nodeType===1){if(!p){t.sizcache=l;t.sizset=q}if(typeof h!=="string"){if(t===h){y=true;break}}else if(k.filter(h,[t]).length>0){y=t;break}}t=t[g]}m[q]=y}}}var f=/((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^[\]]*\]|['"][^'"]*['"]|[^[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g,
+e=0,j=Object.prototype.toString,i=false,o=true;[0,0].sort(function(){o=false;return 0});var k=function(g,h,l,m){l=l||[];var q=h=h||s;if(h.nodeType!==1&&h.nodeType!==9)return[];if(!g||typeof g!=="string")return l;for(var p=[],v,t,y,S,H=true,M=x(h),I=g;(f.exec(""),v=f.exec(I))!==null;){I=v[3];p.push(v[1]);if(v[2]){S=v[3];break}}if(p.length>1&&r.exec(g))if(p.length===2&&n.relative[p[0]])t=ga(p[0]+p[1],h);else for(t=n.relative[p[0]]?[h]:k(p.shift(),h);p.length;){g=p.shift();if(n.relative[g])g+=p.shift();
+t=ga(g,t)}else{if(!m&&p.length>1&&h.nodeType===9&&!M&&n.match.ID.test(p[0])&&!n.match.ID.test(p[p.length-1])){v=k.find(p.shift(),h,M);h=v.expr?k.filter(v.expr,v.set)[0]:v.set[0]}if(h){v=m?{expr:p.pop(),set:z(m)}:k.find(p.pop(),p.length===1&&(p[0]==="~"||p[0]==="+")&&h.parentNode?h.parentNode:h,M);t=v.expr?k.filter(v.expr,v.set):v.set;if(p.length>0)y=z(t);else H=false;for(;p.length;){var D=p.pop();v=D;if(n.relative[D])v=p.pop();else D="";if(v==null)v=h;n.relative[D](y,v,M)}}else y=[]}y||(y=t);y||k.error(D||
+g);if(j.call(y)==="[object Array]")if(H)if(h&&h.nodeType===1)for(g=0;y[g]!=null;g++){if(y[g]&&(y[g]===true||y[g].nodeType===1&&E(h,y[g])))l.push(t[g])}else for(g=0;y[g]!=null;g++)y[g]&&y[g].nodeType===1&&l.push(t[g]);else l.push.apply(l,y);else z(y,l);if(S){k(S,q,l,m);k.uniqueSort(l)}return l};k.uniqueSort=function(g){if(B){i=o;g.sort(B);if(i)for(var h=1;h<g.length;h++)g[h]===g[h-1]&&g.splice(h--,1)}return g};k.matches=function(g,h){return k(g,null,null,h)};k.find=function(g,h,l){var m,q;if(!g)return[];
+for(var p=0,v=n.order.length;p<v;p++){var t=n.order[p];if(q=n.leftMatch[t].exec(g)){var y=q[1];q.splice(1,1);if(y.substr(y.length-1)!=="\\"){q[1]=(q[1]||"").replace(/\\/g,"");m=n.find[t](q,h,l);if(m!=null){g=g.replace(n.match[t],"");break}}}}m||(m=h.getElementsByTagName("*"));return{set:m,expr:g}};k.filter=function(g,h,l,m){for(var q=g,p=[],v=h,t,y,S=h&&h[0]&&x(h[0]);g&&h.length;){for(var H in n.filter)if((t=n.leftMatch[H].exec(g))!=null&&t[2]){var M=n.filter[H],I,D;D=t[1];y=false;t.splice(1,1);if(D.substr(D.length-
+1)!=="\\"){if(v===p)p=[];if(n.preFilter[H])if(t=n.preFilter[H](t,v,l,p,m,S)){if(t===true)continue}else y=I=true;if(t)for(var U=0;(D=v[U])!=null;U++)if(D){I=M(D,t,U,v);var Ha=m^!!I;if(l&&I!=null)if(Ha)y=true;else v[U]=false;else if(Ha){p.push(D);y=true}}if(I!==w){l||(v=p);g=g.replace(n.match[H],"");if(!y)return[];break}}}if(g===q)if(y==null)k.error(g);else break;q=g}return v};k.error=function(g){throw"Syntax error, unrecognized expression: "+g;};var n=k.selectors={order:["ID","NAME","TAG"],match:{ID:/#((?:[\w\u00c0-\uFFFF-]|\\.)+)/,
+CLASS:/\.((?:[\w\u00c0-\uFFFF-]|\\.)+)/,NAME:/\[name=['"]*((?:[\w\u00c0-\uFFFF-]|\\.)+)['"]*\]/,ATTR:/\[\s*((?:[\w\u00c0-\uFFFF-]|\\.)+)\s*(?:(\S?=)\s*(['"]*)(.*?)\3|)\s*\]/,TAG:/^((?:[\w\u00c0-\uFFFF\*-]|\\.)+)/,CHILD:/:(only|nth|last|first)-child(?:\((even|odd|[\dn+-]*)\))?/,POS:/:(nth|eq|gt|lt|first|last|even|odd)(?:\((\d*)\))?(?=[^-]|$)/,PSEUDO:/:((?:[\w\u00c0-\uFFFF-]|\\.)+)(?:\((['"]?)((?:\([^\)]+\)|[^\(\)]*)+)\2\))?/},leftMatch:{},attrMap:{"class":"className","for":"htmlFor"},attrHandle:{href:function(g){return g.getAttribute("href")}},
+relative:{"+":function(g,h){var l=typeof h==="string",m=l&&!/\W/.test(h);l=l&&!m;if(m)h=h.toLowerCase();m=0;for(var q=g.length,p;m<q;m++)if(p=g[m]){for(;(p=p.previousSibling)&&p.nodeType!==1;);g[m]=l||p&&p.nodeName.toLowerCase()===h?p||false:p===h}l&&k.filter(h,g,true)},">":function(g,h){var l=typeof h==="string";if(l&&!/\W/.test(h)){h=h.toLowerCase();for(var m=0,q=g.length;m<q;m++){var p=g[m];if(p){l=p.parentNode;g[m]=l.nodeName.toLowerCase()===h?l:false}}}else{m=0;for(q=g.length;m<q;m++)if(p=g[m])g[m]=
+l?p.parentNode:p.parentNode===h;l&&k.filter(h,g,true)}},"":function(g,h,l){var m=e++,q=d;if(typeof h==="string"&&!/\W/.test(h)){var p=h=h.toLowerCase();q=b}q("parentNode",h,m,g,p,l)},"~":function(g,h,l){var m=e++,q=d;if(typeof h==="string"&&!/\W/.test(h)){var p=h=h.toLowerCase();q=b}q("previousSibling",h,m,g,p,l)}},find:{ID:function(g,h,l){if(typeof h.getElementById!=="undefined"&&!l)return(g=h.getElementById(g[1]))?[g]:[]},NAME:function(g,h){if(typeof h.getElementsByName!=="undefined"){var l=[];
+h=h.getElementsByName(g[1]);for(var m=0,q=h.length;m<q;m++)h[m].getAttribute("name")===g[1]&&l.push(h[m]);return l.length===0?null:l}},TAG:function(g,h){return h.getElementsByTagName(g[1])}},preFilter:{CLASS:function(g,h,l,m,q,p){g=" "+g[1].replace(/\\/g,"")+" ";if(p)return g;p=0;for(var v;(v=h[p])!=null;p++)if(v)if(q^(v.className&&(" "+v.className+" ").replace(/[\t\n]/g," ").indexOf(g)>=0))l||m.push(v);else if(l)h[p]=false;return false},ID:function(g){return g[1].replace(/\\/g,"")},TAG:function(g){return g[1].toLowerCase()},
+CHILD:function(g){if(g[1]==="nth"){var h=/(-?)(\d*)n((?:\+|-)?\d*)/.exec(g[2]==="even"&&"2n"||g[2]==="odd"&&"2n+1"||!/\D/.test(g[2])&&"0n+"+g[2]||g[2]);g[2]=h[1]+(h[2]||1)-0;g[3]=h[3]-0}g[0]=e++;return g},ATTR:function(g,h,l,m,q,p){h=g[1].replace(/\\/g,"");if(!p&&n.attrMap[h])g[1]=n.attrMap[h];if(g[2]==="~=")g[4]=" "+g[4]+" ";return g},PSEUDO:function(g,h,l,m,q){if(g[1]==="not")if((f.exec(g[3])||"").length>1||/^\w/.test(g[3]))g[3]=k(g[3],null,null,h);else{g=k.filter(g[3],h,l,true^q);l||m.push.apply(m,
+g);return false}else if(n.match.POS.test(g[0])||n.match.CHILD.test(g[0]))return true;return g},POS:function(g){g.unshift(true);return g}},filters:{enabled:function(g){return g.disabled===false&&g.type!=="hidden"},disabled:function(g){return g.disabled===true},checked:function(g){return g.checked===true},selected:function(g){return g.selected===true},parent:function(g){return!!g.firstChild},empty:function(g){return!g.firstChild},has:function(g,h,l){return!!k(l[3],g).length},header:function(g){return/h\d/i.test(g.nodeName)},
+text:function(g){return"text"===g.type},radio:function(g){return"radio"===g.type},checkbox:function(g){return"checkbox"===g.type},file:function(g){return"file"===g.type},password:function(g){return"password"===g.type},submit:function(g){return"submit"===g.type},image:function(g){return"image"===g.type},reset:function(g){return"reset"===g.type},button:function(g){return"button"===g.type||g.nodeName.toLowerCase()==="button"},input:function(g){return/input|select|textarea|button/i.test(g.nodeName)}},
+setFilters:{first:function(g,h){return h===0},last:function(g,h,l,m){return h===m.length-1},even:function(g,h){return h%2===0},odd:function(g,h){return h%2===1},lt:function(g,h,l){return h<l[3]-0},gt:function(g,h,l){return h>l[3]-0},nth:function(g,h,l){return l[3]-0===h},eq:function(g,h,l){return l[3]-0===h}},filter:{PSEUDO:function(g,h,l,m){var q=h[1],p=n.filters[q];if(p)return p(g,l,h,m);else if(q==="contains")return(g.textContent||g.innerText||a([g])||"").indexOf(h[3])>=0;else if(q==="not"){h=
+h[3];l=0;for(m=h.length;l<m;l++)if(h[l]===g)return false;return true}else k.error("Syntax error, unrecognized expression: "+q)},CHILD:function(g,h){var l=h[1],m=g;switch(l){case "only":case "first":for(;m=m.previousSibling;)if(m.nodeType===1)return false;if(l==="first")return true;m=g;case "last":for(;m=m.nextSibling;)if(m.nodeType===1)return false;return true;case "nth":l=h[2];var q=h[3];if(l===1&&q===0)return true;h=h[0];var p=g.parentNode;if(p&&(p.sizcache!==h||!g.nodeIndex)){var v=0;for(m=p.firstChild;m;m=
+m.nextSibling)if(m.nodeType===1)m.nodeIndex=++v;p.sizcache=h}g=g.nodeIndex-q;return l===0?g===0:g%l===0&&g/l>=0}},ID:function(g,h){return g.nodeType===1&&g.getAttribute("id")===h},TAG:function(g,h){return h==="*"&&g.nodeType===1||g.nodeName.toLowerCase()===h},CLASS:function(g,h){return(" "+(g.className||g.getAttribute("class"))+" ").indexOf(h)>-1},ATTR:function(g,h){var l=h[1];g=n.attrHandle[l]?n.attrHandle[l](g):g[l]!=null?g[l]:g.getAttribute(l);l=g+"";var m=h[2];h=h[4];return g==null?m==="!=":m===
+"="?l===h:m==="*="?l.indexOf(h)>=0:m==="~="?(" "+l+" ").indexOf(h)>=0:!h?l&&g!==false:m==="!="?l!==h:m==="^="?l.indexOf(h)===0:m==="$="?l.substr(l.length-h.length)===h:m==="|="?l===h||l.substr(0,h.length+1)===h+"-":false},POS:function(g,h,l,m){var q=n.setFilters[h[2]];if(q)return q(g,l,h,m)}}},r=n.match.POS;for(var u in n.match){n.match[u]=new RegExp(n.match[u].source+/(?![^\[]*\])(?![^\(]*\))/.source);n.leftMatch[u]=new RegExp(/(^(?:.|\r|\n)*?)/.source+n.match[u].source.replace(/\\(\d+)/g,function(g,
+h){return"\\"+(h-0+1)}))}var z=function(g,h){g=Array.prototype.slice.call(g,0);if(h){h.push.apply(h,g);return h}return g};try{Array.prototype.slice.call(s.documentElement.childNodes,0)}catch(C){z=function(g,h){h=h||[];if(j.call(g)==="[object Array]")Array.prototype.push.apply(h,g);else if(typeof g.length==="number")for(var l=0,m=g.length;l<m;l++)h.push(g[l]);else for(l=0;g[l];l++)h.push(g[l]);return h}}var B;if(s.documentElement.compareDocumentPosition)B=function(g,h){if(!g.compareDocumentPosition||
+!h.compareDocumentPosition){if(g==h)i=true;return g.compareDocumentPosition?-1:1}g=g.compareDocumentPosition(h)&4?-1:g===h?0:1;if(g===0)i=true;return g};else if("sourceIndex"in s.documentElement)B=function(g,h){if(!g.sourceIndex||!h.sourceIndex){if(g==h)i=true;return g.sourceIndex?-1:1}g=g.sourceIndex-h.sourceIndex;if(g===0)i=true;return g};else if(s.createRange)B=function(g,h){if(!g.ownerDocument||!h.ownerDocument){if(g==h)i=true;return g.ownerDocument?-1:1}var l=g.ownerDocument.createRange(),m=
+h.ownerDocument.createRange();l.setStart(g,0);l.setEnd(g,0);m.setStart(h,0);m.setEnd(h,0);g=l.compareBoundaryPoints(Range.START_TO_END,m);if(g===0)i=true;return g};(function(){var g=s.createElement("div"),h="script"+(new Date).getTime();g.innerHTML="<a name='"+h+"'/>";var l=s.documentElement;l.insertBefore(g,l.firstChild);if(s.getElementById(h)){n.find.ID=function(m,q,p){if(typeof q.getElementById!=="undefined"&&!p)return(q=q.getElementById(m[1]))?q.id===m[1]||typeof q.getAttributeNode!=="undefined"&&
+q.getAttributeNode("id").nodeValue===m[1]?[q]:w:[]};n.filter.ID=function(m,q){var p=typeof m.getAttributeNode!=="undefined"&&m.getAttributeNode("id");return m.nodeType===1&&p&&p.nodeValue===q}}l.removeChild(g);l=g=null})();(function(){var g=s.createElement("div");g.appendChild(s.createComment(""));if(g.getElementsByTagName("*").length>0)n.find.TAG=function(h,l){l=l.getElementsByTagName(h[1]);if(h[1]==="*"){h=[];for(var m=0;l[m];m++)l[m].nodeType===1&&h.push(l[m]);l=h}return l};g.innerHTML="<a href='#'></a>";
+if(g.firstChild&&typeof g.firstChild.getAttribute!=="undefined"&&g.firstChild.getAttribute("href")!=="#")n.attrHandle.href=function(h){return h.getAttribute("href",2)};g=null})();s.querySelectorAll&&function(){var g=k,h=s.createElement("div");h.innerHTML="<p class='TEST'></p>";if(!(h.querySelectorAll&&h.querySelectorAll(".TEST").length===0)){k=function(m,q,p,v){q=q||s;if(!v&&q.nodeType===9&&!x(q))try{return z(q.querySelectorAll(m),p)}catch(t){}return g(m,q,p,v)};for(var l in g)k[l]=g[l];h=null}}();
+(function(){var g=s.createElement("div");g.innerHTML="<div class='test e'></div><div class='test'></div>";if(!(!g.getElementsByClassName||g.getElementsByClassName("e").length===0)){g.lastChild.className="e";if(g.getElementsByClassName("e").length!==1){n.order.splice(1,0,"CLASS");n.find.CLASS=function(h,l,m){if(typeof l.getElementsByClassName!=="undefined"&&!m)return l.getElementsByClassName(h[1])};g=null}}})();var E=s.compareDocumentPosition?function(g,h){return!!(g.compareDocumentPosition(h)&16)}:
+function(g,h){return g!==h&&(g.contains?g.contains(h):true)},x=function(g){return(g=(g?g.ownerDocument||g:0).documentElement)?g.nodeName!=="HTML":false},ga=function(g,h){var l=[],m="",q;for(h=h.nodeType?[h]:h;q=n.match.PSEUDO.exec(g);){m+=q[0];g=g.replace(n.match.PSEUDO,"")}g=n.relative[g]?g+"*":g;q=0;for(var p=h.length;q<p;q++)k(g,h[q],l);return k.filter(m,l)};c.find=k;c.expr=k.selectors;c.expr[":"]=c.expr.filters;c.unique=k.uniqueSort;c.text=a;c.isXMLDoc=x;c.contains=E})();var eb=/Until$/,fb=/^(?:parents|prevUntil|prevAll)/,
+gb=/,/;R=Array.prototype.slice;var Ia=function(a,b,d){if(c.isFunction(b))return c.grep(a,function(e,j){return!!b.call(e,j,e)===d});else if(b.nodeType)return c.grep(a,function(e){return e===b===d});else if(typeof b==="string"){var f=c.grep(a,function(e){return e.nodeType===1});if(Ua.test(b))return c.filter(b,f,!d);else b=c.filter(b,f)}return c.grep(a,function(e){return c.inArray(e,b)>=0===d})};c.fn.extend({find:function(a){for(var b=this.pushStack("","find",a),d=0,f=0,e=this.length;f<e;f++){d=b.length;
+c.find(a,this[f],b);if(f>0)for(var j=d;j<b.length;j++)for(var i=0;i<d;i++)if(b[i]===b[j]){b.splice(j--,1);break}}return b},has:function(a){var b=c(a);return this.filter(function(){for(var d=0,f=b.length;d<f;d++)if(c.contains(this,b[d]))return true})},not:function(a){return this.pushStack(Ia(this,a,false),"not",a)},filter:function(a){return this.pushStack(Ia(this,a,true),"filter",a)},is:function(a){return!!a&&c.filter(a,this).length>0},closest:function(a,b){if(c.isArray(a)){var d=[],f=this[0],e,j=
+{},i;if(f&&a.length){e=0;for(var o=a.length;e<o;e++){i=a[e];j[i]||(j[i]=c.expr.match.POS.test(i)?c(i,b||this.context):i)}for(;f&&f.ownerDocument&&f!==b;){for(i in j){e=j[i];if(e.jquery?e.index(f)>-1:c(f).is(e)){d.push({selector:i,elem:f});delete j[i]}}f=f.parentNode}}return d}var k=c.expr.match.POS.test(a)?c(a,b||this.context):null;return this.map(function(n,r){for(;r&&r.ownerDocument&&r!==b;){if(k?k.index(r)>-1:c(r).is(a))return r;r=r.parentNode}return null})},index:function(a){if(!a||typeof a===
+"string")return c.inArray(this[0],a?c(a):this.parent().children());return c.inArray(a.jquery?a[0]:a,this)},add:function(a,b){a=typeof a==="string"?c(a,b||this.context):c.makeArray(a);b=c.merge(this.get(),a);return this.pushStack(qa(a[0])||qa(b[0])?b:c.unique(b))},andSelf:function(){return this.add(this.prevObject)}});c.each({parent:function(a){return(a=a.parentNode)&&a.nodeType!==11?a:null},parents:function(a){return c.dir(a,"parentNode")},parentsUntil:function(a,b,d){return c.dir(a,"parentNode",
+d)},next:function(a){return c.nth(a,2,"nextSibling")},prev:function(a){return c.nth(a,2,"previousSibling")},nextAll:function(a){return c.dir(a,"nextSibling")},prevAll:function(a){return c.dir(a,"previousSibling")},nextUntil:function(a,b,d){return c.dir(a,"nextSibling",d)},prevUntil:function(a,b,d){return c.dir(a,"previousSibling",d)},siblings:function(a){return c.sibling(a.parentNode.firstChild,a)},children:function(a){return c.sibling(a.firstChild)},contents:function(a){return c.nodeName(a,"iframe")?
+a.contentDocument||a.contentWindow.document:c.makeArray(a.childNodes)}},function(a,b){c.fn[a]=function(d,f){var e=c.map(this,b,d);eb.test(a)||(f=d);if(f&&typeof f==="string")e=c.filter(f,e);e=this.length>1?c.unique(e):e;if((this.length>1||gb.test(f))&&fb.test(a))e=e.reverse();return this.pushStack(e,a,R.call(arguments).join(","))}});c.extend({filter:function(a,b,d){if(d)a=":not("+a+")";return c.find.matches(a,b)},dir:function(a,b,d){var f=[];for(a=a[b];a&&a.nodeType!==9&&(d===w||a.nodeType!==1||!c(a).is(d));){a.nodeType===
+1&&f.push(a);a=a[b]}return f},nth:function(a,b,d){b=b||1;for(var f=0;a;a=a[d])if(a.nodeType===1&&++f===b)break;return a},sibling:function(a,b){for(var d=[];a;a=a.nextSibling)a.nodeType===1&&a!==b&&d.push(a);return d}});var Ja=/ jQuery\d+="(?:\d+|null)"/g,V=/^\s+/,Ka=/(<([\w:]+)[^>]*?)\/>/g,hb=/^(?:area|br|col|embed|hr|img|input|link|meta|param)$/i,La=/<([\w:]+)/,ib=/<tbody/i,jb=/<|&#?\w+;/,ta=/<script|<object|<embed|<option|<style/i,ua=/checked\s*(?:[^=]|=\s*.checked.)/i,Ma=function(a,b,d){return hb.test(d)?
+a:b+"></"+d+">"},F={option:[1,"<select multiple='multiple'>","</select>"],legend:[1,"<fieldset>","</fieldset>"],thead:[1,"<table>","</table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],col:[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"],area:[1,"<map>","</map>"],_default:[0,"",""]};F.optgroup=F.option;F.tbody=F.tfoot=F.colgroup=F.caption=F.thead;F.th=F.td;if(!c.support.htmlSerialize)F._default=[1,"div<div>","</div>"];c.fn.extend({text:function(a){if(c.isFunction(a))return this.each(function(b){var d=
+c(this);d.text(a.call(this,b,d.text()))});if(typeof a!=="object"&&a!==w)return this.empty().append((this[0]&&this[0].ownerDocument||s).createTextNode(a));return c.text(this)},wrapAll:function(a){if(c.isFunction(a))return this.each(function(d){c(this).wrapAll(a.call(this,d))});if(this[0]){var b=c(a,this[0].ownerDocument).eq(0).clone(true);this[0].parentNode&&b.insertBefore(this[0]);b.map(function(){for(var d=this;d.firstChild&&d.firstChild.nodeType===1;)d=d.firstChild;return d}).append(this)}return this},
+wrapInner:function(a){if(c.isFunction(a))return this.each(function(b){c(this).wrapInner(a.call(this,b))});return this.each(function(){var b=c(this),d=b.contents();d.length?d.wrapAll(a):b.append(a)})},wrap:function(a){return this.each(function(){c(this).wrapAll(a)})},unwrap:function(){return this.parent().each(function(){c.nodeName(this,"body")||c(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.appendChild(a)})},
+prepend:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,this)});else if(arguments.length){var a=c(arguments[0]);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,
+this.nextSibling)});else if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,c(arguments[0]).toArray());return a}},remove:function(a,b){for(var d=0,f;(f=this[d])!=null;d++)if(!a||c.filter(a,[f]).length){if(!b&&f.nodeType===1){c.cleanData(f.getElementsByTagName("*"));c.cleanData([f])}f.parentNode&&f.parentNode.removeChild(f)}return this},empty:function(){for(var a=0,b;(b=this[a])!=null;a++)for(b.nodeType===1&&c.cleanData(b.getElementsByTagName("*"));b.firstChild;)b.removeChild(b.firstChild);
+return this},clone:function(a){var b=this.map(function(){if(!c.support.noCloneEvent&&!c.isXMLDoc(this)){var d=this.outerHTML,f=this.ownerDocument;if(!d){d=f.createElement("div");d.appendChild(this.cloneNode(true));d=d.innerHTML}return c.clean([d.replace(Ja,"").replace(/=([^="'>\s]+\/)>/g,'="$1">').replace(V,"")],f)[0]}else return this.cloneNode(true)});if(a===true){ra(this,b);ra(this.find("*"),b.find("*"))}return b},html:function(a){if(a===w)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(Ja,
+""):null;else if(typeof a==="string"&&!ta.test(a)&&(c.support.leadingWhitespace||!V.test(a))&&!F[(La.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Ka,Ma);try{for(var b=0,d=this.length;b<d;b++)if(this[b].nodeType===1){c.cleanData(this[b].getElementsByTagName("*"));this[b].innerHTML=a}}catch(f){this.empty().append(a)}}else c.isFunction(a)?this.each(function(e){var j=c(this),i=j.html();j.empty().append(function(){return a.call(this,e,i)})}):this.empty().append(a);return this},replaceWith:function(a){if(this[0]&&
+this[0].parentNode){if(c.isFunction(a))return this.each(function(b){var d=c(this),f=d.html();d.replaceWith(a.call(this,b,f))});if(typeof a!=="string")a=c(a).detach();return this.each(function(){var b=this.nextSibling,d=this.parentNode;c(this).remove();b?c(b).before(a):c(d).append(a)})}else return this.pushStack(c(c.isFunction(a)?a():a),"replaceWith",a)},detach:function(a){return this.remove(a,true)},domManip:function(a,b,d){function f(u){return c.nodeName(u,"table")?u.getElementsByTagName("tbody")[0]||
+u.appendChild(u.ownerDocument.createElement("tbody")):u}var e,j,i=a[0],o=[],k;if(!c.support.checkClone&&arguments.length===3&&typeof i==="string"&&ua.test(i))return this.each(function(){c(this).domManip(a,b,d,true)});if(c.isFunction(i))return this.each(function(u){var z=c(this);a[0]=i.call(this,u,b?z.html():w);z.domManip(a,b,d)});if(this[0]){e=i&&i.parentNode;e=c.support.parentNode&&e&&e.nodeType===11&&e.childNodes.length===this.length?{fragment:e}:sa(a,this,o);k=e.fragment;if(j=k.childNodes.length===
+1?(k=k.firstChild):k.firstChild){b=b&&c.nodeName(j,"tr");for(var n=0,r=this.length;n<r;n++)d.call(b?f(this[n],j):this[n],n>0||e.cacheable||this.length>1?k.cloneNode(true):k)}o.length&&c.each(o,Qa)}return this}});c.fragments={};c.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){c.fn[a]=function(d){var f=[];d=c(d);var e=this.length===1&&this[0].parentNode;if(e&&e.nodeType===11&&e.childNodes.length===1&&d.length===1){d[b](this[0]);
+return this}else{e=0;for(var j=d.length;e<j;e++){var i=(e>0?this.clone(true):this).get();c.fn[b].apply(c(d[e]),i);f=f.concat(i)}return this.pushStack(f,a,d.selector)}}});c.extend({clean:function(a,b,d,f){b=b||s;if(typeof b.createElement==="undefined")b=b.ownerDocument||b[0]&&b[0].ownerDocument||s;for(var e=[],j=0,i;(i=a[j])!=null;j++){if(typeof i==="number")i+="";if(i){if(typeof i==="string"&&!jb.test(i))i=b.createTextNode(i);else if(typeof i==="string"){i=i.replace(Ka,Ma);var o=(La.exec(i)||["",
+""])[1].toLowerCase(),k=F[o]||F._default,n=k[0],r=b.createElement("div");for(r.innerHTML=k[1]+i+k[2];n--;)r=r.lastChild;if(!c.support.tbody){n=ib.test(i);o=o==="table"&&!n?r.firstChild&&r.firstChild.childNodes:k[1]==="<table>"&&!n?r.childNodes:[];for(k=o.length-1;k>=0;--k)c.nodeName(o[k],"tbody")&&!o[k].childNodes.length&&o[k].parentNode.removeChild(o[k])}!c.support.leadingWhitespace&&V.test(i)&&r.insertBefore(b.createTextNode(V.exec(i)[0]),r.firstChild);i=r.childNodes}if(i.nodeType)e.push(i);else e=
+c.merge(e,i)}}if(d)for(j=0;e[j];j++)if(f&&c.nodeName(e[j],"script")&&(!e[j].type||e[j].type.toLowerCase()==="text/javascript"))f.push(e[j].parentNode?e[j].parentNode.removeChild(e[j]):e[j]);else{e[j].nodeType===1&&e.splice.apply(e,[j+1,0].concat(c.makeArray(e[j].getElementsByTagName("script"))));d.appendChild(e[j])}return e},cleanData:function(a){for(var b,d,f=c.cache,e=c.event.special,j=c.support.deleteExpando,i=0,o;(o=a[i])!=null;i++)if(d=o[c.expando]){b=f[d];if(b.events)for(var k in b.events)e[k]?
+c.event.remove(o,k):Ca(o,k,b.handle);if(j)delete o[c.expando];else o.removeAttribute&&o.removeAttribute(c.expando);delete f[d]}}});var kb=/z-?index|font-?weight|opacity|zoom|line-?height/i,Na=/alpha\([^)]*\)/,Oa=/opacity=([^)]*)/,ha=/float/i,ia=/-([a-z])/ig,lb=/([A-Z])/g,mb=/^-?\d+(?:px)?$/i,nb=/^-?\d/,ob={position:"absolute",visibility:"hidden",display:"block"},pb=["Left","Right"],qb=["Top","Bottom"],rb=s.defaultView&&s.defaultView.getComputedStyle,Pa=c.support.cssFloat?"cssFloat":"styleFloat",ja=
+function(a,b){return b.toUpperCase()};c.fn.css=function(a,b){return X(this,a,b,true,function(d,f,e){if(e===w)return c.curCSS(d,f);if(typeof e==="number"&&!kb.test(f))e+="px";c.style(d,f,e)})};c.extend({style:function(a,b,d){if(!a||a.nodeType===3||a.nodeType===8)return w;if((b==="width"||b==="height")&&parseFloat(d)<0)d=w;var f=a.style||a,e=d!==w;if(!c.support.opacity&&b==="opacity"){if(e){f.zoom=1;b=parseInt(d,10)+""==="NaN"?"":"alpha(opacity="+d*100+")";a=f.filter||c.curCSS(a,"filter")||"";f.filter=
+Na.test(a)?a.replace(Na,b):b}return f.filter&&f.filter.indexOf("opacity=")>=0?parseFloat(Oa.exec(f.filter)[1])/100+"":""}if(ha.test(b))b=Pa;b=b.replace(ia,ja);if(e)f[b]=d;return f[b]},css:function(a,b,d,f){if(b==="width"||b==="height"){var e,j=b==="width"?pb:qb;function i(){e=b==="width"?a.offsetWidth:a.offsetHeight;f!=="border"&&c.each(j,function(){f||(e-=parseFloat(c.curCSS(a,"padding"+this,true))||0);if(f==="margin")e+=parseFloat(c.curCSS(a,"margin"+this,true))||0;else e-=parseFloat(c.curCSS(a,
+"border"+this+"Width",true))||0})}a.offsetWidth!==0?i():c.swap(a,ob,i);return Math.max(0,Math.round(e))}return c.curCSS(a,b,d)},curCSS:function(a,b,d){var f,e=a.style;if(!c.support.opacity&&b==="opacity"&&a.currentStyle){f=Oa.test(a.currentStyle.filter||"")?parseFloat(RegExp.$1)/100+"":"";return f===""?"1":f}if(ha.test(b))b=Pa;if(!d&&e&&e[b])f=e[b];else if(rb){if(ha.test(b))b="float";b=b.replace(lb,"-$1").toLowerCase();e=a.ownerDocument.defaultView;if(!e)return null;if(a=e.getComputedStyle(a,null))f=
+a.getPropertyValue(b);if(b==="opacity"&&f==="")f="1"}else if(a.currentStyle){d=b.replace(ia,ja);f=a.currentStyle[b]||a.currentStyle[d];if(!mb.test(f)&&nb.test(f)){b=e.left;var j=a.runtimeStyle.left;a.runtimeStyle.left=a.currentStyle.left;e.left=d==="fontSize"?"1em":f||0;f=e.pixelLeft+"px";e.left=b;a.runtimeStyle.left=j}}return f},swap:function(a,b,d){var f={};for(var e in b){f[e]=a.style[e];a.style[e]=b[e]}d.call(a);for(e in b)a.style[e]=f[e]}});if(c.expr&&c.expr.filters){c.expr.filters.hidden=function(a){var b=
+a.offsetWidth,d=a.offsetHeight,f=a.nodeName.toLowerCase()==="tr";return b===0&&d===0&&!f?true:b>0&&d>0&&!f?false:c.curCSS(a,"display")==="none"};c.expr.filters.visible=function(a){return!c.expr.filters.hidden(a)}}var sb=J(),tb=/<script(.|\s)*?\/script>/gi,ub=/select|textarea/i,vb=/color|date|datetime|email|hidden|month|number|password|range|search|tel|text|time|url|week/i,N=/=\?(&|$)/,ka=/\?/,wb=/(\?|&)_=.*?(&|$)/,xb=/^(\w+:)?\/\/([^\/?#]+)/,yb=/%20/g,zb=c.fn.load;c.fn.extend({load:function(a,b,d){if(typeof a!==
+"string")return zb.call(this,a);else if(!this.length)return this;var f=a.indexOf(" ");if(f>=0){var e=a.slice(f,a.length);a=a.slice(0,f)}f="GET";if(b)if(c.isFunction(b)){d=b;b=null}else if(typeof b==="object"){b=c.param(b,c.ajaxSettings.traditional);f="POST"}var j=this;c.ajax({url:a,type:f,dataType:"html",data:b,complete:function(i,o){if(o==="success"||o==="notmodified")j.html(e?c("<div />").append(i.responseText.replace(tb,"")).find(e):i.responseText);d&&j.each(d,[i.responseText,o,i])}});return this},
+serialize:function(){return c.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?c.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||ub.test(this.nodeName)||vb.test(this.type))}).map(function(a,b){a=c(this).val();return a==null?null:c.isArray(a)?c.map(a,function(d){return{name:b.name,value:d}}):{name:b.name,value:a}}).get()}});c.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "),
+function(a,b){c.fn[b]=function(d){return this.bind(b,d)}});c.extend({get:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b=null}return c.ajax({type:"GET",url:a,data:b,success:d,dataType:f})},getScript:function(a,b){return c.get(a,null,b,"script")},getJSON:function(a,b,d){return c.get(a,b,d,"json")},post:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b={}}return c.ajax({type:"POST",url:a,data:b,success:d,dataType:f})},ajaxSetup:function(a){c.extend(c.ajaxSettings,a)},ajaxSettings:{url:location.href,
+global:true,type:"GET",contentType:"application/x-www-form-urlencoded",processData:true,async:true,xhr:A.XMLHttpRequest&&(A.location.protocol!=="file:"||!A.ActiveXObject)?function(){return new A.XMLHttpRequest}:function(){try{return new A.ActiveXObject("Microsoft.XMLHTTP")}catch(a){}},accepts:{xml:"application/xml, text/xml",html:"text/html",script:"text/javascript, application/javascript",json:"application/json, text/javascript",text:"text/plain",_default:"*/*"}},lastModified:{},etag:{},ajax:function(a){function b(){e.success&&
+e.success.call(k,o,i,x);e.global&&f("ajaxSuccess",[x,e])}function d(){e.complete&&e.complete.call(k,x,i);e.global&&f("ajaxComplete",[x,e]);e.global&&!--c.active&&c.event.trigger("ajaxStop")}function f(q,p){(e.context?c(e.context):c.event).trigger(q,p)}var e=c.extend(true,{},c.ajaxSettings,a),j,i,o,k=a&&a.context||e,n=e.type.toUpperCase();if(e.data&&e.processData&&typeof e.data!=="string")e.data=c.param(e.data,e.traditional);if(e.dataType==="jsonp"){if(n==="GET")N.test(e.url)||(e.url+=(ka.test(e.url)?
+"&":"?")+(e.jsonp||"callback")+"=?");else if(!e.data||!N.test(e.data))e.data=(e.data?e.data+"&":"")+(e.jsonp||"callback")+"=?";e.dataType="json"}if(e.dataType==="json"&&(e.data&&N.test(e.data)||N.test(e.url))){j=e.jsonpCallback||"jsonp"+sb++;if(e.data)e.data=(e.data+"").replace(N,"="+j+"$1");e.url=e.url.replace(N,"="+j+"$1");e.dataType="script";A[j]=A[j]||function(q){o=q;b();d();A[j]=w;try{delete A[j]}catch(p){}z&&z.removeChild(C)}}if(e.dataType==="script"&&e.cache===null)e.cache=false;if(e.cache===
+false&&n==="GET"){var r=J(),u=e.url.replace(wb,"$1_="+r+"$2");e.url=u+(u===e.url?(ka.test(e.url)?"&":"?")+"_="+r:"")}if(e.data&&n==="GET")e.url+=(ka.test(e.url)?"&":"?")+e.data;e.global&&!c.active++&&c.event.trigger("ajaxStart");r=(r=xb.exec(e.url))&&(r[1]&&r[1]!==location.protocol||r[2]!==location.host);if(e.dataType==="script"&&n==="GET"&&r){var z=s.getElementsByTagName("head")[0]||s.documentElement,C=s.createElement("script");C.src=e.url;if(e.scriptCharset)C.charset=e.scriptCharset;if(!j){var B=
+false;C.onload=C.onreadystatechange=function(){if(!B&&(!this.readyState||this.readyState==="loaded"||this.readyState==="complete")){B=true;b();d();C.onload=C.onreadystatechange=null;z&&C.parentNode&&z.removeChild(C)}}}z.insertBefore(C,z.firstChild);return w}var E=false,x=e.xhr();if(x){e.username?x.open(n,e.url,e.async,e.username,e.password):x.open(n,e.url,e.async);try{if(e.data||a&&a.contentType)x.setRequestHeader("Content-Type",e.contentType);if(e.ifModified){c.lastModified[e.url]&&x.setRequestHeader("If-Modified-Since",
+c.lastModified[e.url]);c.etag[e.url]&&x.setRequestHeader("If-None-Match",c.etag[e.url])}r||x.setRequestHeader("X-Requested-With","XMLHttpRequest");x.setRequestHeader("Accept",e.dataType&&e.accepts[e.dataType]?e.accepts[e.dataType]+", */*":e.accepts._default)}catch(ga){}if(e.beforeSend&&e.beforeSend.call(k,x,e)===false){e.global&&!--c.active&&c.event.trigger("ajaxStop");x.abort();return false}e.global&&f("ajaxSend",[x,e]);var g=x.onreadystatechange=function(q){if(!x||x.readyState===0||q==="abort"){E||
+d();E=true;if(x)x.onreadystatechange=c.noop}else if(!E&&x&&(x.readyState===4||q==="timeout")){E=true;x.onreadystatechange=c.noop;i=q==="timeout"?"timeout":!c.httpSuccess(x)?"error":e.ifModified&&c.httpNotModified(x,e.url)?"notmodified":"success";var p;if(i==="success")try{o=c.httpData(x,e.dataType,e)}catch(v){i="parsererror";p=v}if(i==="success"||i==="notmodified")j||b();else c.handleError(e,x,i,p);d();q==="timeout"&&x.abort();if(e.async)x=null}};try{var h=x.abort;x.abort=function(){x&&h.call(x);
+g("abort")}}catch(l){}e.async&&e.timeout>0&&setTimeout(function(){x&&!E&&g("timeout")},e.timeout);try{x.send(n==="POST"||n==="PUT"||n==="DELETE"?e.data:null)}catch(m){c.handleError(e,x,null,m);d()}e.async||g();return x}},handleError:function(a,b,d,f){if(a.error)a.error.call(a.context||a,b,d,f);if(a.global)(a.context?c(a.context):c.event).trigger("ajaxError",[b,a,f])},active:0,httpSuccess:function(a){try{return!a.status&&location.protocol==="file:"||a.status>=200&&a.status<300||a.status===304||a.status===
+1223||a.status===0}catch(b){}return false},httpNotModified:function(a,b){var d=a.getResponseHeader("Last-Modified"),f=a.getResponseHeader("Etag");if(d)c.lastModified[b]=d;if(f)c.etag[b]=f;return a.status===304||a.status===0},httpData:function(a,b,d){var f=a.getResponseHeader("content-type")||"",e=b==="xml"||!b&&f.indexOf("xml")>=0;a=e?a.responseXML:a.responseText;e&&a.documentElement.nodeName==="parsererror"&&c.error("parsererror");if(d&&d.dataFilter)a=d.dataFilter(a,b);if(typeof a==="string")if(b===
+"json"||!b&&f.indexOf("json")>=0)a=c.parseJSON(a);else if(b==="script"||!b&&f.indexOf("javascript")>=0)c.globalEval(a);return a},param:function(a,b){function d(i,o){if(c.isArray(o))c.each(o,function(k,n){b||/\[\]$/.test(i)?f(i,n):d(i+"["+(typeof n==="object"||c.isArray(n)?k:"")+"]",n)});else!b&&o!=null&&typeof o==="object"?c.each(o,function(k,n){d(i+"["+k+"]",n)}):f(i,o)}function f(i,o){o=c.isFunction(o)?o():o;e[e.length]=encodeURIComponent(i)+"="+encodeURIComponent(o)}var e=[];if(b===w)b=c.ajaxSettings.traditional;
+if(c.isArray(a)||a.jquery)c.each(a,function(){f(this.name,this.value)});else for(var j in a)d(j,a[j]);return e.join("&").replace(yb,"+")}});var la={},Ab=/toggle|show|hide/,Bb=/^([+-]=)?([\d+-.]+)(.*)$/,W,va=[["height","marginTop","marginBottom","paddingTop","paddingBottom"],["width","marginLeft","marginRight","paddingLeft","paddingRight"],["opacity"]];c.fn.extend({show:function(a,b){if(a||a===0)return this.animate(K("show",3),a,b);else{a=0;for(b=this.length;a<b;a++){var d=c.data(this[a],"olddisplay");
+this[a].style.display=d||"";if(c.css(this[a],"display")==="none"){d=this[a].nodeName;var f;if(la[d])f=la[d];else{var e=c("<"+d+" />").appendTo("body");f=e.css("display");if(f==="none")f="block";e.remove();la[d]=f}c.data(this[a],"olddisplay",f)}}a=0;for(b=this.length;a<b;a++)this[a].style.display=c.data(this[a],"olddisplay")||"";return this}},hide:function(a,b){if(a||a===0)return this.animate(K("hide",3),a,b);else{a=0;for(b=this.length;a<b;a++){var d=c.data(this[a],"olddisplay");!d&&d!=="none"&&c.data(this[a],
+"olddisplay",c.css(this[a],"display"))}a=0;for(b=this.length;a<b;a++)this[a].style.display="none";return this}},_toggle:c.fn.toggle,toggle:function(a,b){var d=typeof a==="boolean";if(c.isFunction(a)&&c.isFunction(b))this._toggle.apply(this,arguments);else a==null||d?this.each(function(){var f=d?a:c(this).is(":hidden");c(this)[f?"show":"hide"]()}):this.animate(K("toggle",3),a,b);return this},fadeTo:function(a,b,d){return this.filter(":hidden").css("opacity",0).show().end().animate({opacity:b},a,d)},
+animate:function(a,b,d,f){var e=c.speed(b,d,f);if(c.isEmptyObject(a))return this.each(e.complete);return this[e.queue===false?"each":"queue"](function(){var j=c.extend({},e),i,o=this.nodeType===1&&c(this).is(":hidden"),k=this;for(i in a){var n=i.replace(ia,ja);if(i!==n){a[n]=a[i];delete a[i];i=n}if(a[i]==="hide"&&o||a[i]==="show"&&!o)return j.complete.call(this);if((i==="height"||i==="width")&&this.style){j.display=c.css(this,"display");j.overflow=this.style.overflow}if(c.isArray(a[i])){(j.specialEasing=
+j.specialEasing||{})[i]=a[i][1];a[i]=a[i][0]}}if(j.overflow!=null)this.style.overflow="hidden";j.curAnim=c.extend({},a);c.each(a,function(r,u){var z=new c.fx(k,j,r);if(Ab.test(u))z[u==="toggle"?o?"show":"hide":u](a);else{var C=Bb.exec(u),B=z.cur(true)||0;if(C){u=parseFloat(C[2]);var E=C[3]||"px";if(E!=="px"){k.style[r]=(u||1)+E;B=(u||1)/z.cur(true)*B;k.style[r]=B+E}if(C[1])u=(C[1]==="-="?-1:1)*u+B;z.custom(B,u,E)}else z.custom(B,u,"")}});return true})},stop:function(a,b){var d=c.timers;a&&this.queue([]);
+this.each(function(){for(var f=d.length-1;f>=0;f--)if(d[f].elem===this){b&&d[f](true);d.splice(f,1)}});b||this.dequeue();return this}});c.each({slideDown:K("show",1),slideUp:K("hide",1),slideToggle:K("toggle",1),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"}},function(a,b){c.fn[a]=function(d,f){return this.animate(b,d,f)}});c.extend({speed:function(a,b,d){var f=a&&typeof a==="object"?a:{complete:d||!d&&b||c.isFunction(a)&&a,duration:a,easing:d&&b||b&&!c.isFunction(b)&&b};f.duration=c.fx.off?0:typeof f.duration===
+"number"?f.duration:c.fx.speeds[f.duration]||c.fx.speeds._default;f.old=f.complete;f.complete=function(){f.queue!==false&&c(this).dequeue();c.isFunction(f.old)&&f.old.call(this)};return f},easing:{linear:function(a,b,d,f){return d+f*a},swing:function(a,b,d,f){return(-Math.cos(a*Math.PI)/2+0.5)*f+d}},timers:[],fx:function(a,b,d){this.options=b;this.elem=a;this.prop=d;if(!b.orig)b.orig={}}});c.fx.prototype={update:function(){this.options.step&&this.options.step.call(this.elem,this.now,this);(c.fx.step[this.prop]||
+c.fx.step._default)(this);if((this.prop==="height"||this.prop==="width")&&this.elem.style)this.elem.style.display="block"},cur:function(a){if(this.elem[this.prop]!=null&&(!this.elem.style||this.elem.style[this.prop]==null))return this.elem[this.prop];return(a=parseFloat(c.css(this.elem,this.prop,a)))&&a>-10000?a:parseFloat(c.curCSS(this.elem,this.prop))||0},custom:function(a,b,d){function f(j){return e.step(j)}this.startTime=J();this.start=a;this.end=b;this.unit=d||this.unit||"px";this.now=this.start;
+this.pos=this.state=0;var e=this;f.elem=this.elem;if(f()&&c.timers.push(f)&&!W)W=setInterval(c.fx.tick,13)},show:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.show=true;this.custom(this.prop==="width"||this.prop==="height"?1:0,this.cur());c(this.elem).show()},hide:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.hide=true;this.custom(this.cur(),0)},step:function(a){var b=J(),d=true;if(a||b>=this.options.duration+this.startTime){this.now=
+this.end;this.pos=this.state=1;this.update();this.options.curAnim[this.prop]=true;for(var f in this.options.curAnim)if(this.options.curAnim[f]!==true)d=false;if(d){if(this.options.display!=null){this.elem.style.overflow=this.options.overflow;a=c.data(this.elem,"olddisplay");this.elem.style.display=a?a:this.options.display;if(c.css(this.elem,"display")==="none")this.elem.style.display="block"}this.options.hide&&c(this.elem).hide();if(this.options.hide||this.options.show)for(var e in this.options.curAnim)c.style(this.elem,
+e,this.options.orig[e]);this.options.complete.call(this.elem)}return false}else{e=b-this.startTime;this.state=e/this.options.duration;a=this.options.easing||(c.easing.swing?"swing":"linear");this.pos=c.easing[this.options.specialEasing&&this.options.specialEasing[this.prop]||a](this.state,e,0,1,this.options.duration);this.now=this.start+(this.end-this.start)*this.pos;this.update()}return true}};c.extend(c.fx,{tick:function(){for(var a=c.timers,b=0;b<a.length;b++)a[b]()||a.splice(b--,1);a.length||
+c.fx.stop()},stop:function(){clearInterval(W);W=null},speeds:{slow:600,fast:200,_default:400},step:{opacity:function(a){c.style(a.elem,"opacity",a.now)},_default:function(a){if(a.elem.style&&a.elem.style[a.prop]!=null)a.elem.style[a.prop]=(a.prop==="width"||a.prop==="height"?Math.max(0,a.now):a.now)+a.unit;else a.elem[a.prop]=a.now}}});if(c.expr&&c.expr.filters)c.expr.filters.animated=function(a){return c.grep(c.timers,function(b){return a===b.elem}).length};c.fn.offset="getBoundingClientRect"in s.documentElement?
+function(a){var b=this[0];if(a)return this.each(function(e){c.offset.setOffset(this,a,e)});if(!b||!b.ownerDocument)return null;if(b===b.ownerDocument.body)return c.offset.bodyOffset(b);var d=b.getBoundingClientRect(),f=b.ownerDocument;b=f.body;f=f.documentElement;return{top:d.top+(self.pageYOffset||c.support.boxModel&&f.scrollTop||b.scrollTop)-(f.clientTop||b.clientTop||0),left:d.left+(self.pageXOffset||c.support.boxModel&&f.scrollLeft||b.scrollLeft)-(f.clientLeft||b.clientLeft||0)}}:function(a){var b=
+this[0];if(a)return this.each(function(r){c.offset.setOffset(this,a,r)});if(!b||!b.ownerDocument)return null;if(b===b.ownerDocument.body)return c.offset.bodyOffset(b);c.offset.initialize();var d=b.offsetParent,f=b,e=b.ownerDocument,j,i=e.documentElement,o=e.body;f=(e=e.defaultView)?e.getComputedStyle(b,null):b.currentStyle;for(var k=b.offsetTop,n=b.offsetLeft;(b=b.parentNode)&&b!==o&&b!==i;){if(c.offset.supportsFixedPosition&&f.position==="fixed")break;j=e?e.getComputedStyle(b,null):b.currentStyle;
+k-=b.scrollTop;n-=b.scrollLeft;if(b===d){k+=b.offsetTop;n+=b.offsetLeft;if(c.offset.doesNotAddBorder&&!(c.offset.doesAddBorderForTableAndCells&&/^t(able|d|h)$/i.test(b.nodeName))){k+=parseFloat(j.borderTopWidth)||0;n+=parseFloat(j.borderLeftWidth)||0}f=d;d=b.offsetParent}if(c.offset.subtractsBorderForOverflowNotVisible&&j.overflow!=="visible"){k+=parseFloat(j.borderTopWidth)||0;n+=parseFloat(j.borderLeftWidth)||0}f=j}if(f.position==="relative"||f.position==="static"){k+=o.offsetTop;n+=o.offsetLeft}if(c.offset.supportsFixedPosition&&
+f.position==="fixed"){k+=Math.max(i.scrollTop,o.scrollTop);n+=Math.max(i.scrollLeft,o.scrollLeft)}return{top:k,left:n}};c.offset={initialize:function(){var a=s.body,b=s.createElement("div"),d,f,e,j=parseFloat(c.curCSS(a,"marginTop",true))||0;c.extend(b.style,{position:"absolute",top:0,left:0,margin:0,border:0,width:"1px",height:"1px",visibility:"hidden"});b.innerHTML="<div style='position:absolute;top:0;left:0;margin:0;border:5px solid #000;padding:0;width:1px;height:1px;'><div></div></div><table style='position:absolute;top:0;left:0;margin:0;border:5px solid #000;padding:0;width:1px;height:1px;' cellpadding='0' cellspacing='0'><tr><td></td></tr></table>";
+a.insertBefore(b,a.firstChild);d=b.firstChild;f=d.firstChild;e=d.nextSibling.firstChild.firstChild;this.doesNotAddBorder=f.offsetTop!==5;this.doesAddBorderForTableAndCells=e.offsetTop===5;f.style.position="fixed";f.style.top="20px";this.supportsFixedPosition=f.offsetTop===20||f.offsetTop===15;f.style.position=f.style.top="";d.style.overflow="hidden";d.style.position="relative";this.subtractsBorderForOverflowNotVisible=f.offsetTop===-5;this.doesNotIncludeMarginInBodyOffset=a.offsetTop!==j;a.removeChild(b);
+c.offset.initialize=c.noop},bodyOffset:function(a){var b=a.offsetTop,d=a.offsetLeft;c.offset.initialize();if(c.offset.doesNotIncludeMarginInBodyOffset){b+=parseFloat(c.curCSS(a,"marginTop",true))||0;d+=parseFloat(c.curCSS(a,"marginLeft",true))||0}return{top:b,left:d}},setOffset:function(a,b,d){if(/static/.test(c.curCSS(a,"position")))a.style.position="relative";var f=c(a),e=f.offset(),j=parseInt(c.curCSS(a,"top",true),10)||0,i=parseInt(c.curCSS(a,"left",true),10)||0;if(c.isFunction(b))b=b.call(a,
+d,e);d={top:b.top-e.top+j,left:b.left-e.left+i};"using"in b?b.using.call(a,d):f.css(d)}};c.fn.extend({position:function(){if(!this[0])return null;var a=this[0],b=this.offsetParent(),d=this.offset(),f=/^body|html$/i.test(b[0].nodeName)?{top:0,left:0}:b.offset();d.top-=parseFloat(c.curCSS(a,"marginTop",true))||0;d.left-=parseFloat(c.curCSS(a,"marginLeft",true))||0;f.top+=parseFloat(c.curCSS(b[0],"borderTopWidth",true))||0;f.left+=parseFloat(c.curCSS(b[0],"borderLeftWidth",true))||0;return{top:d.top-
+f.top,left:d.left-f.left}},offsetParent:function(){return this.map(function(){for(var a=this.offsetParent||s.body;a&&!/^body|html$/i.test(a.nodeName)&&c.css(a,"position")==="static";)a=a.offsetParent;return a})}});c.each(["Left","Top"],function(a,b){var d="scroll"+b;c.fn[d]=function(f){var e=this[0],j;if(!e)return null;if(f!==w)return this.each(function(){if(j=wa(this))j.scrollTo(!a?f:c(j).scrollLeft(),a?f:c(j).scrollTop());else this[d]=f});else return(j=wa(e))?"pageXOffset"in j?j[a?"pageYOffset":
+"pageXOffset"]:c.support.boxModel&&j.document.documentElement[d]||j.document.body[d]:e[d]}});c.each(["Height","Width"],function(a,b){var d=b.toLowerCase();c.fn["inner"+b]=function(){return this[0]?c.css(this[0],d,false,"padding"):null};c.fn["outer"+b]=function(f){return this[0]?c.css(this[0],d,false,f?"margin":"border"):null};c.fn[d]=function(f){var e=this[0];if(!e)return f==null?null:this;if(c.isFunction(f))return this.each(function(j){var i=c(this);i[d](f.call(this,j,i[d]()))});return"scrollTo"in
+e&&e.document?e.document.compatMode==="CSS1Compat"&&e.document.documentElement["client"+b]||e.document.body["client"+b]:e.nodeType===9?Math.max(e.documentElement["client"+b],e.body["scroll"+b],e.documentElement["scroll"+b],e.body["offset"+b],e.documentElement["offset"+b]):f===w?c.css(e,d):this.css(d,typeof f==="string"?f:f+"px")}});A.jQuery=A.$=c})(window);
diff --git a/web/js/jquery-ui-1.8.2.custom.min.js b/web/js/jquery-ui-1.8.2.custom.min.js
new file mode 100755 (executable)
index 0000000..c11e844
--- /dev/null
@@ -0,0 +1,1012 @@
+/*!
+ * jQuery UI 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI
+ */
+(function(c){c.ui=c.ui||{};if(!c.ui.version){c.extend(c.ui,{version:"1.8.2",plugin:{add:function(a,b,d){a=c.ui[a].prototype;for(var e in d){a.plugins[e]=a.plugins[e]||[];a.plugins[e].push([b,d[e]])}},call:function(a,b,d){if((b=a.plugins[b])&&a.element[0].parentNode)for(var e=0;e<b.length;e++)a.options[b[e][0]]&&b[e][1].apply(a.element,d)}},contains:function(a,b){return document.compareDocumentPosition?a.compareDocumentPosition(b)&16:a!==b&&a.contains(b)},hasScroll:function(a,b){if(c(a).css("overflow")==
+"hidden")return false;b=b&&b=="left"?"scrollLeft":"scrollTop";var d=false;if(a[b]>0)return true;a[b]=1;d=a[b]>0;a[b]=0;return d},isOverAxis:function(a,b,d){return a>b&&a<b+d},isOver:function(a,b,d,e,f,g){return c.ui.isOverAxis(a,d,f)&&c.ui.isOverAxis(b,e,g)},keyCode:{ALT:18,BACKSPACE:8,CAPS_LOCK:20,COMMA:188,COMMAND:91,COMMAND_LEFT:91,COMMAND_RIGHT:93,CONTROL:17,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,INSERT:45,LEFT:37,MENU:93,NUMPAD_ADD:107,NUMPAD_DECIMAL:110,NUMPAD_DIVIDE:111,NUMPAD_ENTER:108,
+NUMPAD_MULTIPLY:106,NUMPAD_SUBTRACT:109,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SHIFT:16,SPACE:32,TAB:9,UP:38,WINDOWS:91}});c.fn.extend({_focus:c.fn.focus,focus:function(a,b){return typeof a==="number"?this.each(function(){var d=this;setTimeout(function(){c(d).focus();b&&b.call(d)},a)}):this._focus.apply(this,arguments)},enableSelection:function(){return this.attr("unselectable","off").css("MozUserSelect","")},disableSelection:function(){return this.attr("unselectable","on").css("MozUserSelect",
+"none")},scrollParent:function(){var a;a=c.browser.msie&&/(static|relative)/.test(this.css("position"))||/absolute/.test(this.css("position"))?this.parents().filter(function(){return/(relative|absolute|fixed)/.test(c.curCSS(this,"position",1))&&/(auto|scroll)/.test(c.curCSS(this,"overflow",1)+c.curCSS(this,"overflow-y",1)+c.curCSS(this,"overflow-x",1))}).eq(0):this.parents().filter(function(){return/(auto|scroll)/.test(c.curCSS(this,"overflow",1)+c.curCSS(this,"overflow-y",1)+c.curCSS(this,"overflow-x",
+1))}).eq(0);return/fixed/.test(this.css("position"))||!a.length?c(document):a},zIndex:function(a){if(a!==undefined)return this.css("zIndex",a);if(this.length){a=c(this[0]);for(var b;a.length&&a[0]!==document;){b=a.css("position");if(b=="absolute"||b=="relative"||b=="fixed"){b=parseInt(a.css("zIndex"));if(!isNaN(b)&&b!=0)return b}a=a.parent()}}return 0}});c.extend(c.expr[":"],{data:function(a,b,d){return!!c.data(a,d[3])},focusable:function(a){var b=a.nodeName.toLowerCase(),d=c.attr(a,"tabindex");return(/input|select|textarea|button|object/.test(b)?
+!a.disabled:"a"==b||"area"==b?a.href||!isNaN(d):!isNaN(d))&&!c(a)["area"==b?"parents":"closest"](":hidden").length},tabbable:function(a){var b=c.attr(a,"tabindex");return(isNaN(b)||b>=0)&&c(a).is(":focusable")}})}})(jQuery);
+;/*!
+ * jQuery UI Widget 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Widget
+ */
+(function(b){var j=b.fn.remove;b.fn.remove=function(a,c){return this.each(function(){if(!c)if(!a||b.filter(a,[this]).length)b("*",this).add(this).each(function(){b(this).triggerHandler("remove")});return j.call(b(this),a,c)})};b.widget=function(a,c,d){var e=a.split(".")[0],f;a=a.split(".")[1];f=e+"-"+a;if(!d){d=c;c=b.Widget}b.expr[":"][f]=function(h){return!!b.data(h,a)};b[e]=b[e]||{};b[e][a]=function(h,g){arguments.length&&this._createWidget(h,g)};c=new c;c.options=b.extend({},c.options);b[e][a].prototype=
+b.extend(true,c,{namespace:e,widgetName:a,widgetEventPrefix:b[e][a].prototype.widgetEventPrefix||a,widgetBaseClass:f},d);b.widget.bridge(a,b[e][a])};b.widget.bridge=function(a,c){b.fn[a]=function(d){var e=typeof d==="string",f=Array.prototype.slice.call(arguments,1),h=this;d=!e&&f.length?b.extend.apply(null,[true,d].concat(f)):d;if(e&&d.substring(0,1)==="_")return h;e?this.each(function(){var g=b.data(this,a),i=g&&b.isFunction(g[d])?g[d].apply(g,f):g;if(i!==g&&i!==undefined){h=i;return false}}):this.each(function(){var g=
+b.data(this,a);if(g){d&&g.option(d);g._init()}else b.data(this,a,new c(d,this))});return h}};b.Widget=function(a,c){arguments.length&&this._createWidget(a,c)};b.Widget.prototype={widgetName:"widget",widgetEventPrefix:"",options:{disabled:false},_createWidget:function(a,c){this.element=b(c).data(this.widgetName,this);this.options=b.extend(true,{},this.options,b.metadata&&b.metadata.get(c)[this.widgetName],a);var d=this;this.element.bind("remove."+this.widgetName,function(){d.destroy()});this._create();
+this._init()},_create:function(){},_init:function(){},destroy:function(){this.element.unbind("."+this.widgetName).removeData(this.widgetName);this.widget().unbind("."+this.widgetName).removeAttr("aria-disabled").removeClass(this.widgetBaseClass+"-disabled ui-state-disabled")},widget:function(){return this.element},option:function(a,c){var d=a,e=this;if(arguments.length===0)return b.extend({},e.options);if(typeof a==="string"){if(c===undefined)return this.options[a];d={};d[a]=c}b.each(d,function(f,
+h){e._setOption(f,h)});return e},_setOption:function(a,c){this.options[a]=c;if(a==="disabled")this.widget()[c?"addClass":"removeClass"](this.widgetBaseClass+"-disabled ui-state-disabled").attr("aria-disabled",c);return this},enable:function(){return this._setOption("disabled",false)},disable:function(){return this._setOption("disabled",true)},_trigger:function(a,c,d){var e=this.options[a];c=b.Event(c);c.type=(a===this.widgetEventPrefix?a:this.widgetEventPrefix+a).toLowerCase();d=d||{};if(c.originalEvent){a=
+b.event.props.length;for(var f;a;){f=b.event.props[--a];c[f]=c.originalEvent[f]}}this.element.trigger(c,d);return!(b.isFunction(e)&&e.call(this.element[0],c,d)===false||c.isDefaultPrevented())}}})(jQuery);
+;/*!
+ * jQuery UI Mouse 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Mouse
+ *
+ * Depends:
+ *     jquery.ui.widget.js
+ */
+(function(c){c.widget("ui.mouse",{options:{cancel:":input,option",distance:1,delay:0},_mouseInit:function(){var a=this;this.element.bind("mousedown."+this.widgetName,function(b){return a._mouseDown(b)}).bind("click."+this.widgetName,function(b){if(a._preventClickEvent){a._preventClickEvent=false;b.stopImmediatePropagation();return false}});this.started=false},_mouseDestroy:function(){this.element.unbind("."+this.widgetName)},_mouseDown:function(a){a.originalEvent=a.originalEvent||{};if(!a.originalEvent.mouseHandled){this._mouseStarted&&
+this._mouseUp(a);this._mouseDownEvent=a;var b=this,e=a.which==1,f=typeof this.options.cancel=="string"?c(a.target).parents().add(a.target).filter(this.options.cancel).length:false;if(!e||f||!this._mouseCapture(a))return true;this.mouseDelayMet=!this.options.delay;if(!this.mouseDelayMet)this._mouseDelayTimer=setTimeout(function(){b.mouseDelayMet=true},this.options.delay);if(this._mouseDistanceMet(a)&&this._mouseDelayMet(a)){this._mouseStarted=this._mouseStart(a)!==false;if(!this._mouseStarted){a.preventDefault();
+return true}}this._mouseMoveDelegate=function(d){return b._mouseMove(d)};this._mouseUpDelegate=function(d){return b._mouseUp(d)};c(document).bind("mousemove."+this.widgetName,this._mouseMoveDelegate).bind("mouseup."+this.widgetName,this._mouseUpDelegate);c.browser.safari||a.preventDefault();return a.originalEvent.mouseHandled=true}},_mouseMove:function(a){if(c.browser.msie&&!a.button)return this._mouseUp(a);if(this._mouseStarted){this._mouseDrag(a);return a.preventDefault()}if(this._mouseDistanceMet(a)&&
+this._mouseDelayMet(a))(this._mouseStarted=this._mouseStart(this._mouseDownEvent,a)!==false)?this._mouseDrag(a):this._mouseUp(a);return!this._mouseStarted},_mouseUp:function(a){c(document).unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate);if(this._mouseStarted){this._mouseStarted=false;this._preventClickEvent=a.target==this._mouseDownEvent.target;this._mouseStop(a)}return false},_mouseDistanceMet:function(a){return Math.max(Math.abs(this._mouseDownEvent.pageX-
+a.pageX),Math.abs(this._mouseDownEvent.pageY-a.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return true}})})(jQuery);
+;/*
+ * jQuery UI Position 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Position
+ */
+(function(c){c.ui=c.ui||{};var m=/left|center|right/,n=/top|center|bottom/,p=c.fn.position,q=c.fn.offset;c.fn.position=function(a){if(!a||!a.of)return p.apply(this,arguments);a=c.extend({},a);var b=c(a.of),d=(a.collision||"flip").split(" "),e=a.offset?a.offset.split(" "):[0,0],g,h,i;if(a.of.nodeType===9){g=b.width();h=b.height();i={top:0,left:0}}else if(a.of.scrollTo&&a.of.document){g=b.width();h=b.height();i={top:b.scrollTop(),left:b.scrollLeft()}}else if(a.of.preventDefault){a.at="left top";g=h=
+0;i={top:a.of.pageY,left:a.of.pageX}}else{g=b.outerWidth();h=b.outerHeight();i=b.offset()}c.each(["my","at"],function(){var f=(a[this]||"").split(" ");if(f.length===1)f=m.test(f[0])?f.concat(["center"]):n.test(f[0])?["center"].concat(f):["center","center"];f[0]=m.test(f[0])?f[0]:"center";f[1]=n.test(f[1])?f[1]:"center";a[this]=f});if(d.length===1)d[1]=d[0];e[0]=parseInt(e[0],10)||0;if(e.length===1)e[1]=e[0];e[1]=parseInt(e[1],10)||0;if(a.at[0]==="right")i.left+=g;else if(a.at[0]==="center")i.left+=
+g/2;if(a.at[1]==="bottom")i.top+=h;else if(a.at[1]==="center")i.top+=h/2;i.left+=e[0];i.top+=e[1];return this.each(function(){var f=c(this),k=f.outerWidth(),l=f.outerHeight(),j=c.extend({},i);if(a.my[0]==="right")j.left-=k;else if(a.my[0]==="center")j.left-=k/2;if(a.my[1]==="bottom")j.top-=l;else if(a.my[1]==="center")j.top-=l/2;j.left=parseInt(j.left);j.top=parseInt(j.top);c.each(["left","top"],function(o,r){c.ui.position[d[o]]&&c.ui.position[d[o]][r](j,{targetWidth:g,targetHeight:h,elemWidth:k,
+elemHeight:l,offset:e,my:a.my,at:a.at})});c.fn.bgiframe&&f.bgiframe();f.offset(c.extend(j,{using:a.using}))})};c.ui.position={fit:{left:function(a,b){var d=c(window);b=a.left+b.elemWidth-d.width()-d.scrollLeft();a.left=b>0?a.left-b:Math.max(0,a.left)},top:function(a,b){var d=c(window);b=a.top+b.elemHeight-d.height()-d.scrollTop();a.top=b>0?a.top-b:Math.max(0,a.top)}},flip:{left:function(a,b){if(b.at[0]!=="center"){var d=c(window);d=a.left+b.elemWidth-d.width()-d.scrollLeft();var e=b.my[0]==="left"?
+-b.elemWidth:b.my[0]==="right"?b.elemWidth:0,g=-2*b.offset[0];a.left+=a.left<0?e+b.targetWidth+g:d>0?e-b.targetWidth+g:0}},top:function(a,b){if(b.at[1]!=="center"){var d=c(window);d=a.top+b.elemHeight-d.height()-d.scrollTop();var e=b.my[1]==="top"?-b.elemHeight:b.my[1]==="bottom"?b.elemHeight:0,g=b.at[1]==="top"?b.targetHeight:-b.targetHeight,h=-2*b.offset[1];a.top+=a.top<0?e+b.targetHeight+h:d>0?e+g+h:0}}}};if(!c.offset.setOffset){c.offset.setOffset=function(a,b){if(/static/.test(c.curCSS(a,"position")))a.style.position=
+"relative";var d=c(a),e=d.offset(),g=parseInt(c.curCSS(a,"top",true),10)||0,h=parseInt(c.curCSS(a,"left",true),10)||0;e={top:b.top-e.top+g,left:b.left-e.left+h};"using"in b?b.using.call(a,e):d.css(e)};c.fn.offset=function(a){var b=this[0];if(!b||!b.ownerDocument)return null;if(a)return this.each(function(){c.offset.setOffset(this,a)});return q.call(this)}}})(jQuery);
+;/*
+ * jQuery UI Draggable 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Draggables
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.mouse.js
+ *     jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.draggable",d.ui.mouse,{widgetEventPrefix:"drag",options:{addClasses:true,appendTo:"parent",axis:false,connectToSortable:false,containment:false,cursor:"auto",cursorAt:false,grid:false,handle:false,helper:"original",iframeFix:false,opacity:false,refreshPositions:false,revert:false,revertDuration:500,scope:"default",scroll:true,scrollSensitivity:20,scrollSpeed:20,snap:false,snapMode:"both",snapTolerance:20,stack:false,zIndex:false},_create:function(){if(this.options.helper==
+"original"&&!/^(?:r|a|f)/.test(this.element.css("position")))this.element[0].style.position="relative";this.options.addClasses&&this.element.addClass("ui-draggable");this.options.disabled&&this.element.addClass("ui-draggable-disabled");this._mouseInit()},destroy:function(){if(this.element.data("draggable")){this.element.removeData("draggable").unbind(".draggable").removeClass("ui-draggable ui-draggable-dragging ui-draggable-disabled");this._mouseDestroy();return this}},_mouseCapture:function(a){var b=
+this.options;if(this.helper||b.disabled||d(a.target).is(".ui-resizable-handle"))return false;this.handle=this._getHandle(a);if(!this.handle)return false;return true},_mouseStart:function(a){var b=this.options;this.helper=this._createHelper(a);this._cacheHelperProportions();if(d.ui.ddmanager)d.ui.ddmanager.current=this;this._cacheMargins();this.cssPosition=this.helper.css("position");this.scrollParent=this.helper.scrollParent();this.offset=this.positionAbs=this.element.offset();this.offset={top:this.offset.top-
+this.margins.top,left:this.offset.left-this.margins.left};d.extend(this.offset,{click:{left:a.pageX-this.offset.left,top:a.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()});this.originalPosition=this.position=this._generatePosition(a);this.originalPageX=a.pageX;this.originalPageY=a.pageY;b.cursorAt&&this._adjustOffsetFromHelper(b.cursorAt);b.containment&&this._setContainment();if(this._trigger("start",a)===false){this._clear();return false}this._cacheHelperProportions();
+d.ui.ddmanager&&!b.dropBehaviour&&d.ui.ddmanager.prepareOffsets(this,a);this.helper.addClass("ui-draggable-dragging");this._mouseDrag(a,true);return true},_mouseDrag:function(a,b){this.position=this._generatePosition(a);this.positionAbs=this._convertPositionTo("absolute");if(!b){b=this._uiHash();if(this._trigger("drag",a,b)===false){this._mouseUp({});return false}this.position=b.position}if(!this.options.axis||this.options.axis!="y")this.helper[0].style.left=this.position.left+"px";if(!this.options.axis||
+this.options.axis!="x")this.helper[0].style.top=this.position.top+"px";d.ui.ddmanager&&d.ui.ddmanager.drag(this,a);return false},_mouseStop:function(a){var b=false;if(d.ui.ddmanager&&!this.options.dropBehaviour)b=d.ui.ddmanager.drop(this,a);if(this.dropped){b=this.dropped;this.dropped=false}if(!this.element[0]||!this.element[0].parentNode)return false;if(this.options.revert=="invalid"&&!b||this.options.revert=="valid"&&b||this.options.revert===true||d.isFunction(this.options.revert)&&this.options.revert.call(this.element,
+b)){var c=this;d(this.helper).animate(this.originalPosition,parseInt(this.options.revertDuration,10),function(){c._trigger("stop",a)!==false&&c._clear()})}else this._trigger("stop",a)!==false&&this._clear();return false},cancel:function(){this.helper.is(".ui-draggable-dragging")?this._mouseUp({}):this._clear();return this},_getHandle:function(a){var b=!this.options.handle||!d(this.options.handle,this.element).length?true:false;d(this.options.handle,this.element).find("*").andSelf().each(function(){if(this==
+a.target)b=true});return b},_createHelper:function(a){var b=this.options;a=d.isFunction(b.helper)?d(b.helper.apply(this.element[0],[a])):b.helper=="clone"?this.element.clone():this.element;a.parents("body").length||a.appendTo(b.appendTo=="parent"?this.element[0].parentNode:b.appendTo);a[0]!=this.element[0]&&!/(fixed|absolute)/.test(a.css("position"))&&a.css("position","absolute");return a},_adjustOffsetFromHelper:function(a){if(typeof a=="string")a=a.split(" ");if(d.isArray(a))a={left:+a[0],top:+a[1]||
+0};if("left"in a)this.offset.click.left=a.left+this.margins.left;if("right"in a)this.offset.click.left=this.helperProportions.width-a.right+this.margins.left;if("top"in a)this.offset.click.top=a.top+this.margins.top;if("bottom"in a)this.offset.click.top=this.helperProportions.height-a.bottom+this.margins.top},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var a=this.offsetParent.offset();if(this.cssPosition=="absolute"&&this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],
+this.offsetParent[0])){a.left+=this.scrollParent.scrollLeft();a.top+=this.scrollParent.scrollTop()}if(this.offsetParent[0]==document.body||this.offsetParent[0].tagName&&this.offsetParent[0].tagName.toLowerCase()=="html"&&d.browser.msie)a={top:0,left:0};return{top:a.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:a.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if(this.cssPosition=="relative"){var a=this.element.position();return{top:a.top-
+(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:a.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}else return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.element.css("marginLeft"),10)||0,top:parseInt(this.element.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var a=this.options;if(a.containment==
+"parent")a.containment=this.helper[0].parentNode;if(a.containment=="document"||a.containment=="window")this.containment=[0-this.offset.relative.left-this.offset.parent.left,0-this.offset.relative.top-this.offset.parent.top,d(a.containment=="document"?document:window).width()-this.helperProportions.width-this.margins.left,(d(a.containment=="document"?document:window).height()||document.body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top];if(!/^(document|window|parent)$/.test(a.containment)&&
+a.containment.constructor!=Array){var b=d(a.containment)[0];if(b){a=d(a.containment).offset();var c=d(b).css("overflow")!="hidden";this.containment=[a.left+(parseInt(d(b).css("borderLeftWidth"),10)||0)+(parseInt(d(b).css("paddingLeft"),10)||0)-this.margins.left,a.top+(parseInt(d(b).css("borderTopWidth"),10)||0)+(parseInt(d(b).css("paddingTop"),10)||0)-this.margins.top,a.left+(c?Math.max(b.scrollWidth,b.offsetWidth):b.offsetWidth)-(parseInt(d(b).css("borderLeftWidth"),10)||0)-(parseInt(d(b).css("paddingRight"),
+10)||0)-this.helperProportions.width-this.margins.left,a.top+(c?Math.max(b.scrollHeight,b.offsetHeight):b.offsetHeight)-(parseInt(d(b).css("borderTopWidth"),10)||0)-(parseInt(d(b).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top]}}else if(a.containment.constructor==Array)this.containment=a.containment},_convertPositionTo:function(a,b){if(!b)b=this.position;a=a=="absolute"?1:-1;var c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],
+this.offsetParent[0]))?this.offsetParent:this.scrollParent,f=/(html|body)/i.test(c[0].tagName);return{top:b.top+this.offset.relative.top*a+this.offset.parent.top*a-(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollTop():f?0:c.scrollTop())*a),left:b.left+this.offset.relative.left*a+this.offset.parent.left*a-(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():
+f?0:c.scrollLeft())*a)}},_generatePosition:function(a){var b=this.options,c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?this.offsetParent:this.scrollParent,f=/(html|body)/i.test(c[0].tagName),e=a.pageX,g=a.pageY;if(this.originalPosition){if(this.containment){if(a.pageX-this.offset.click.left<this.containment[0])e=this.containment[0]+this.offset.click.left;if(a.pageY-this.offset.click.top<this.containment[1])g=this.containment[1]+
+this.offset.click.top;if(a.pageX-this.offset.click.left>this.containment[2])e=this.containment[2]+this.offset.click.left;if(a.pageY-this.offset.click.top>this.containment[3])g=this.containment[3]+this.offset.click.top}if(b.grid){g=this.originalPageY+Math.round((g-this.originalPageY)/b.grid[1])*b.grid[1];g=this.containment?!(g-this.offset.click.top<this.containment[1]||g-this.offset.click.top>this.containment[3])?g:!(g-this.offset.click.top<this.containment[1])?g-b.grid[1]:g+b.grid[1]:g;e=this.originalPageX+
+Math.round((e-this.originalPageX)/b.grid[0])*b.grid[0];e=this.containment?!(e-this.offset.click.left<this.containment[0]||e-this.offset.click.left>this.containment[2])?e:!(e-this.offset.click.left<this.containment[0])?e-b.grid[0]:e+b.grid[0]:e}}return{top:g-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollTop():f?0:c.scrollTop()),left:e-this.offset.click.left-
+this.offset.relative.left-this.offset.parent.left+(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():f?0:c.scrollLeft())}},_clear:function(){this.helper.removeClass("ui-draggable-dragging");this.helper[0]!=this.element[0]&&!this.cancelHelperRemoval&&this.helper.remove();this.helper=null;this.cancelHelperRemoval=false},_trigger:function(a,b,c){c=c||this._uiHash();d.ui.plugin.call(this,a,[b,c]);if(a=="drag")this.positionAbs=
+this._convertPositionTo("absolute");return d.Widget.prototype._trigger.call(this,a,b,c)},plugins:{},_uiHash:function(){return{helper:this.helper,position:this.position,originalPosition:this.originalPosition,offset:this.positionAbs}}});d.extend(d.ui.draggable,{version:"1.8.2"});d.ui.plugin.add("draggable","connectToSortable",{start:function(a,b){var c=d(this).data("draggable"),f=c.options,e=d.extend({},b,{item:c.element});c.sortables=[];d(f.connectToSortable).each(function(){var g=d.data(this,"sortable");
+if(g&&!g.options.disabled){c.sortables.push({instance:g,shouldRevert:g.options.revert});g._refreshItems();g._trigger("activate",a,e)}})},stop:function(a,b){var c=d(this).data("draggable"),f=d.extend({},b,{item:c.element});d.each(c.sortables,function(){if(this.instance.isOver){this.instance.isOver=0;c.cancelHelperRemoval=true;this.instance.cancelHelperRemoval=false;if(this.shouldRevert)this.instance.options.revert=true;this.instance._mouseStop(a);this.instance.options.helper=this.instance.options._helper;
+c.options.helper=="original"&&this.instance.currentItem.css({top:"auto",left:"auto"})}else{this.instance.cancelHelperRemoval=false;this.instance._trigger("deactivate",a,f)}})},drag:function(a,b){var c=d(this).data("draggable"),f=this;d.each(c.sortables,function(){this.instance.positionAbs=c.positionAbs;this.instance.helperProportions=c.helperProportions;this.instance.offset.click=c.offset.click;if(this.instance._intersectsWith(this.instance.containerCache)){if(!this.instance.isOver){this.instance.isOver=
+1;this.instance.currentItem=d(f).clone().appendTo(this.instance.element).data("sortable-item",true);this.instance.options._helper=this.instance.options.helper;this.instance.options.helper=function(){return b.helper[0]};a.target=this.instance.currentItem[0];this.instance._mouseCapture(a,true);this.instance._mouseStart(a,true,true);this.instance.offset.click.top=c.offset.click.top;this.instance.offset.click.left=c.offset.click.left;this.instance.offset.parent.left-=c.offset.parent.left-this.instance.offset.parent.left;
+this.instance.offset.parent.top-=c.offset.parent.top-this.instance.offset.parent.top;c._trigger("toSortable",a);c.dropped=this.instance.element;c.currentItem=c.element;this.instance.fromOutside=c}this.instance.currentItem&&this.instance._mouseDrag(a)}else if(this.instance.isOver){this.instance.isOver=0;this.instance.cancelHelperRemoval=true;this.instance.options.revert=false;this.instance._trigger("out",a,this.instance._uiHash(this.instance));this.instance._mouseStop(a,true);this.instance.options.helper=
+this.instance.options._helper;this.instance.currentItem.remove();this.instance.placeholder&&this.instance.placeholder.remove();c._trigger("fromSortable",a);c.dropped=false}})}});d.ui.plugin.add("draggable","cursor",{start:function(){var a=d("body"),b=d(this).data("draggable").options;if(a.css("cursor"))b._cursor=a.css("cursor");a.css("cursor",b.cursor)},stop:function(){var a=d(this).data("draggable").options;a._cursor&&d("body").css("cursor",a._cursor)}});d.ui.plugin.add("draggable","iframeFix",{start:function(){var a=
+d(this).data("draggable").options;d(a.iframeFix===true?"iframe":a.iframeFix).each(function(){d('<div class="ui-draggable-iframeFix" style="background: #fff;"></div>').css({width:this.offsetWidth+"px",height:this.offsetHeight+"px",position:"absolute",opacity:"0.001",zIndex:1E3}).css(d(this).offset()).appendTo("body")})},stop:function(){d("div.ui-draggable-iframeFix").each(function(){this.parentNode.removeChild(this)})}});d.ui.plugin.add("draggable","opacity",{start:function(a,b){a=d(b.helper);b=d(this).data("draggable").options;
+if(a.css("opacity"))b._opacity=a.css("opacity");a.css("opacity",b.opacity)},stop:function(a,b){a=d(this).data("draggable").options;a._opacity&&d(b.helper).css("opacity",a._opacity)}});d.ui.plugin.add("draggable","scroll",{start:function(){var a=d(this).data("draggable");if(a.scrollParent[0]!=document&&a.scrollParent[0].tagName!="HTML")a.overflowOffset=a.scrollParent.offset()},drag:function(a){var b=d(this).data("draggable"),c=b.options,f=false;if(b.scrollParent[0]!=document&&b.scrollParent[0].tagName!=
+"HTML"){if(!c.axis||c.axis!="x")if(b.overflowOffset.top+b.scrollParent[0].offsetHeight-a.pageY<c.scrollSensitivity)b.scrollParent[0].scrollTop=f=b.scrollParent[0].scrollTop+c.scrollSpeed;else if(a.pageY-b.overflowOffset.top<c.scrollSensitivity)b.scrollParent[0].scrollTop=f=b.scrollParent[0].scrollTop-c.scrollSpeed;if(!c.axis||c.axis!="y")if(b.overflowOffset.left+b.scrollParent[0].offsetWidth-a.pageX<c.scrollSensitivity)b.scrollParent[0].scrollLeft=f=b.scrollParent[0].scrollLeft+c.scrollSpeed;else if(a.pageX-
+b.overflowOffset.left<c.scrollSensitivity)b.scrollParent[0].scrollLeft=f=b.scrollParent[0].scrollLeft-c.scrollSpeed}else{if(!c.axis||c.axis!="x")if(a.pageY-d(document).scrollTop()<c.scrollSensitivity)f=d(document).scrollTop(d(document).scrollTop()-c.scrollSpeed);else if(d(window).height()-(a.pageY-d(document).scrollTop())<c.scrollSensitivity)f=d(document).scrollTop(d(document).scrollTop()+c.scrollSpeed);if(!c.axis||c.axis!="y")if(a.pageX-d(document).scrollLeft()<c.scrollSensitivity)f=d(document).scrollLeft(d(document).scrollLeft()-
+c.scrollSpeed);else if(d(window).width()-(a.pageX-d(document).scrollLeft())<c.scrollSensitivity)f=d(document).scrollLeft(d(document).scrollLeft()+c.scrollSpeed)}f!==false&&d.ui.ddmanager&&!c.dropBehaviour&&d.ui.ddmanager.prepareOffsets(b,a)}});d.ui.plugin.add("draggable","snap",{start:function(){var a=d(this).data("draggable"),b=a.options;a.snapElements=[];d(b.snap.constructor!=String?b.snap.items||":data(draggable)":b.snap).each(function(){var c=d(this),f=c.offset();this!=a.element[0]&&a.snapElements.push({item:this,
+width:c.outerWidth(),height:c.outerHeight(),top:f.top,left:f.left})})},drag:function(a,b){for(var c=d(this).data("draggable"),f=c.options,e=f.snapTolerance,g=b.offset.left,n=g+c.helperProportions.width,m=b.offset.top,o=m+c.helperProportions.height,h=c.snapElements.length-1;h>=0;h--){var i=c.snapElements[h].left,k=i+c.snapElements[h].width,j=c.snapElements[h].top,l=j+c.snapElements[h].height;if(i-e<g&&g<k+e&&j-e<m&&m<l+e||i-e<g&&g<k+e&&j-e<o&&o<l+e||i-e<n&&n<k+e&&j-e<m&&m<l+e||i-e<n&&n<k+e&&j-e<o&&
+o<l+e){if(f.snapMode!="inner"){var p=Math.abs(j-o)<=e,q=Math.abs(l-m)<=e,r=Math.abs(i-n)<=e,s=Math.abs(k-g)<=e;if(p)b.position.top=c._convertPositionTo("relative",{top:j-c.helperProportions.height,left:0}).top-c.margins.top;if(q)b.position.top=c._convertPositionTo("relative",{top:l,left:0}).top-c.margins.top;if(r)b.position.left=c._convertPositionTo("relative",{top:0,left:i-c.helperProportions.width}).left-c.margins.left;if(s)b.position.left=c._convertPositionTo("relative",{top:0,left:k}).left-c.margins.left}var t=
+p||q||r||s;if(f.snapMode!="outer"){p=Math.abs(j-m)<=e;q=Math.abs(l-o)<=e;r=Math.abs(i-g)<=e;s=Math.abs(k-n)<=e;if(p)b.position.top=c._convertPositionTo("relative",{top:j,left:0}).top-c.margins.top;if(q)b.position.top=c._convertPositionTo("relative",{top:l-c.helperProportions.height,left:0}).top-c.margins.top;if(r)b.position.left=c._convertPositionTo("relative",{top:0,left:i}).left-c.margins.left;if(s)b.position.left=c._convertPositionTo("relative",{top:0,left:k-c.helperProportions.width}).left-c.margins.left}if(!c.snapElements[h].snapping&&
+(p||q||r||s||t))c.options.snap.snap&&c.options.snap.snap.call(c.element,a,d.extend(c._uiHash(),{snapItem:c.snapElements[h].item}));c.snapElements[h].snapping=p||q||r||s||t}else{c.snapElements[h].snapping&&c.options.snap.release&&c.options.snap.release.call(c.element,a,d.extend(c._uiHash(),{snapItem:c.snapElements[h].item}));c.snapElements[h].snapping=false}}}});d.ui.plugin.add("draggable","stack",{start:function(){var a=d(this).data("draggable").options;a=d.makeArray(d(a.stack)).sort(function(c,f){return(parseInt(d(c).css("zIndex"),
+10)||0)-(parseInt(d(f).css("zIndex"),10)||0)});if(a.length){var b=parseInt(a[0].style.zIndex)||0;d(a).each(function(c){this.style.zIndex=b+c});this[0].style.zIndex=b+a.length}}});d.ui.plugin.add("draggable","zIndex",{start:function(a,b){a=d(b.helper);b=d(this).data("draggable").options;if(a.css("zIndex"))b._zIndex=a.css("zIndex");a.css("zIndex",b.zIndex)},stop:function(a,b){a=d(this).data("draggable").options;a._zIndex&&d(b.helper).css("zIndex",a._zIndex)}})})(jQuery);
+;/*
+ * jQuery UI Droppable 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Droppables
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.widget.js
+ *     jquery.ui.mouse.js
+ *     jquery.ui.draggable.js
+ */
+(function(d){d.widget("ui.droppable",{widgetEventPrefix:"drop",options:{accept:"*",activeClass:false,addClasses:true,greedy:false,hoverClass:false,scope:"default",tolerance:"intersect"},_create:function(){var a=this.options,b=a.accept;this.isover=0;this.isout=1;this.accept=d.isFunction(b)?b:function(c){return c.is(b)};this.proportions={width:this.element[0].offsetWidth,height:this.element[0].offsetHeight};d.ui.ddmanager.droppables[a.scope]=d.ui.ddmanager.droppables[a.scope]||[];d.ui.ddmanager.droppables[a.scope].push(this);
+a.addClasses&&this.element.addClass("ui-droppable")},destroy:function(){for(var a=d.ui.ddmanager.droppables[this.options.scope],b=0;b<a.length;b++)a[b]==this&&a.splice(b,1);this.element.removeClass("ui-droppable ui-droppable-disabled").removeData("droppable").unbind(".droppable");return this},_setOption:function(a,b){if(a=="accept")this.accept=d.isFunction(b)?b:function(c){return c.is(b)};d.Widget.prototype._setOption.apply(this,arguments)},_activate:function(a){var b=d.ui.ddmanager.current;this.options.activeClass&&
+this.element.addClass(this.options.activeClass);b&&this._trigger("activate",a,this.ui(b))},_deactivate:function(a){var b=d.ui.ddmanager.current;this.options.activeClass&&this.element.removeClass(this.options.activeClass);b&&this._trigger("deactivate",a,this.ui(b))},_over:function(a){var b=d.ui.ddmanager.current;if(!(!b||(b.currentItem||b.element)[0]==this.element[0]))if(this.accept.call(this.element[0],b.currentItem||b.element)){this.options.hoverClass&&this.element.addClass(this.options.hoverClass);
+this._trigger("over",a,this.ui(b))}},_out:function(a){var b=d.ui.ddmanager.current;if(!(!b||(b.currentItem||b.element)[0]==this.element[0]))if(this.accept.call(this.element[0],b.currentItem||b.element)){this.options.hoverClass&&this.element.removeClass(this.options.hoverClass);this._trigger("out",a,this.ui(b))}},_drop:function(a,b){var c=b||d.ui.ddmanager.current;if(!c||(c.currentItem||c.element)[0]==this.element[0])return false;var e=false;this.element.find(":data(droppable)").not(".ui-draggable-dragging").each(function(){var g=
+d.data(this,"droppable");if(g.options.greedy&&!g.options.disabled&&g.options.scope==c.options.scope&&g.accept.call(g.element[0],c.currentItem||c.element)&&d.ui.intersect(c,d.extend(g,{offset:g.element.offset()}),g.options.tolerance)){e=true;return false}});if(e)return false;if(this.accept.call(this.element[0],c.currentItem||c.element)){this.options.activeClass&&this.element.removeClass(this.options.activeClass);this.options.hoverClass&&this.element.removeClass(this.options.hoverClass);this._trigger("drop",
+a,this.ui(c));return this.element}return false},ui:function(a){return{draggable:a.currentItem||a.element,helper:a.helper,position:a.position,offset:a.positionAbs}}});d.extend(d.ui.droppable,{version:"1.8.2"});d.ui.intersect=function(a,b,c){if(!b.offset)return false;var e=(a.positionAbs||a.position.absolute).left,g=e+a.helperProportions.width,f=(a.positionAbs||a.position.absolute).top,h=f+a.helperProportions.height,i=b.offset.left,k=i+b.proportions.width,j=b.offset.top,l=j+b.proportions.height;
+switch(c){case "fit":return i<e&&g<k&&j<f&&h<l;case "intersect":return i<e+a.helperProportions.width/2&&g-a.helperProportions.width/2<k&&j<f+a.helperProportions.height/2&&h-a.helperProportions.height/2<l;case "pointer":return d.ui.isOver((a.positionAbs||a.position.absolute).top+(a.clickOffset||a.offset.click).top,(a.positionAbs||a.position.absolute).left+(a.clickOffset||a.offset.click).left,j,i,b.proportions.height,b.proportions.width);case "touch":return(f>=j&&f<=l||h>=j&&h<=l||f<j&&h>l)&&(e>=i&&
+e<=k||g>=i&&g<=k||e<i&&g>k);default:return false}};d.ui.ddmanager={current:null,droppables:{"default":[]},prepareOffsets:function(a,b){var c=d.ui.ddmanager.droppables[a.options.scope]||[],e=b?b.type:null,g=(a.currentItem||a.element).find(":data(droppable)").andSelf(),f=0;a:for(;f<c.length;f++)if(!(c[f].options.disabled||a&&!c[f].accept.call(c[f].element[0],a.currentItem||a.element))){for(var h=0;h<g.length;h++)if(g[h]==c[f].element[0]){c[f].proportions.height=0;continue a}c[f].visible=c[f].element.css("display")!=
+"none";if(c[f].visible){c[f].offset=c[f].element.offset();c[f].proportions={width:c[f].element[0].offsetWidth,height:c[f].element[0].offsetHeight};e=="mousedown"&&c[f]._activate.call(c[f],b)}}},drop:function(a,b){var c=false;d.each(d.ui.ddmanager.droppables[a.options.scope]||[],function(){if(this.options){if(!this.options.disabled&&this.visible&&d.ui.intersect(a,this,this.options.tolerance))c=c||this._drop.call(this,b);if(!this.options.disabled&&this.visible&&this.accept.call(this.element[0],a.currentItem||
+a.element)){this.isout=1;this.isover=0;this._deactivate.call(this,b)}}});return c},drag:function(a,b){a.options.refreshPositions&&d.ui.ddmanager.prepareOffsets(a,b);d.each(d.ui.ddmanager.droppables[a.options.scope]||[],function(){if(!(this.options.disabled||this.greedyChild||!this.visible)){var c=d.ui.intersect(a,this,this.options.tolerance);if(c=!c&&this.isover==1?"isout":c&&this.isover==0?"isover":null){var e;if(this.options.greedy){var g=this.element.parents(":data(droppable):eq(0)");if(g.length){e=
+d.data(g[0],"droppable");e.greedyChild=c=="isover"?1:0}}if(e&&c=="isover"){e.isover=0;e.isout=1;e._out.call(e,b)}this[c]=1;this[c=="isout"?"isover":"isout"]=0;this[c=="isover"?"_over":"_out"].call(this,b);if(e&&c=="isout"){e.isout=0;e.isover=1;e._over.call(e,b)}}}})}}})(jQuery);
+;/*
+ * jQuery UI Resizable 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Resizables
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.mouse.js
+ *     jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.resizable",d.ui.mouse,{widgetEventPrefix:"resize",options:{alsoResize:false,animate:false,animateDuration:"slow",animateEasing:"swing",aspectRatio:false,autoHide:false,containment:false,ghost:false,grid:false,handles:"e,s,se",helper:false,maxHeight:null,maxWidth:null,minHeight:10,minWidth:10,zIndex:1E3},_create:function(){var b=this,a=this.options;this.element.addClass("ui-resizable");d.extend(this,{_aspectRatio:!!a.aspectRatio,aspectRatio:a.aspectRatio,originalElement:this.element,
+_proportionallyResizeElements:[],_helper:a.helper||a.ghost||a.animate?a.helper||"ui-resizable-helper":null});if(this.element[0].nodeName.match(/canvas|textarea|input|select|button|img/i)){/relative/.test(this.element.css("position"))&&d.browser.opera&&this.element.css({position:"relative",top:"auto",left:"auto"});this.element.wrap(d('<div class="ui-wrapper" style="overflow: hidden;"></div>').css({position:this.element.css("position"),width:this.element.outerWidth(),height:this.element.outerHeight(),
+top:this.element.css("top"),left:this.element.css("left")}));this.element=this.element.parent().data("resizable",this.element.data("resizable"));this.elementIsWrapper=true;this.element.css({marginLeft:this.originalElement.css("marginLeft"),marginTop:this.originalElement.css("marginTop"),marginRight:this.originalElement.css("marginRight"),marginBottom:this.originalElement.css("marginBottom")});this.originalElement.css({marginLeft:0,marginTop:0,marginRight:0,marginBottom:0});this.originalResizeStyle=
+this.originalElement.css("resize");this.originalElement.css("resize","none");this._proportionallyResizeElements.push(this.originalElement.css({position:"static",zoom:1,display:"block"}));this.originalElement.css({margin:this.originalElement.css("margin")});this._proportionallyResize()}this.handles=a.handles||(!d(".ui-resizable-handle",this.element).length?"e,s,se":{n:".ui-resizable-n",e:".ui-resizable-e",s:".ui-resizable-s",w:".ui-resizable-w",se:".ui-resizable-se",sw:".ui-resizable-sw",ne:".ui-resizable-ne",
+nw:".ui-resizable-nw"});if(this.handles.constructor==String){if(this.handles=="all")this.handles="n,e,s,w,se,sw,ne,nw";var c=this.handles.split(",");this.handles={};for(var e=0;e<c.length;e++){var g=d.trim(c[e]),f=d('<div class="ui-resizable-handle '+("ui-resizable-"+g)+'"></div>');/sw|se|ne|nw/.test(g)&&f.css({zIndex:++a.zIndex});"se"==g&&f.addClass("ui-icon ui-icon-gripsmall-diagonal-se");this.handles[g]=".ui-resizable-"+g;this.element.append(f)}}this._renderAxis=function(h){h=h||this.element;for(var i in this.handles){if(this.handles[i].constructor==
+String)this.handles[i]=d(this.handles[i],this.element).show();if(this.elementIsWrapper&&this.originalElement[0].nodeName.match(/textarea|input|select|button/i)){var j=d(this.handles[i],this.element),l=0;l=/sw|ne|nw|se|n|s/.test(i)?j.outerHeight():j.outerWidth();j=["padding",/ne|nw|n/.test(i)?"Top":/se|sw|s/.test(i)?"Bottom":/^e$/.test(i)?"Right":"Left"].join("");h.css(j,l);this._proportionallyResize()}d(this.handles[i])}};this._renderAxis(this.element);this._handles=d(".ui-resizable-handle",this.element).disableSelection();
+this._handles.mouseover(function(){if(!b.resizing){if(this.className)var h=this.className.match(/ui-resizable-(se|sw|ne|nw|n|e|s|w)/i);b.axis=h&&h[1]?h[1]:"se"}});if(a.autoHide){this._handles.hide();d(this.element).addClass("ui-resizable-autohide").hover(function(){d(this).removeClass("ui-resizable-autohide");b._handles.show()},function(){if(!b.resizing){d(this).addClass("ui-resizable-autohide");b._handles.hide()}})}this._mouseInit()},destroy:function(){this._mouseDestroy();var b=function(c){d(c).removeClass("ui-resizable ui-resizable-disabled ui-resizable-resizing").removeData("resizable").unbind(".resizable").find(".ui-resizable-handle").remove()};
+if(this.elementIsWrapper){b(this.element);var a=this.element;a.after(this.originalElement.css({position:a.css("position"),width:a.outerWidth(),height:a.outerHeight(),top:a.css("top"),left:a.css("left")})).remove()}this.originalElement.css("resize",this.originalResizeStyle);b(this.originalElement);return this},_mouseCapture:function(b){var a=false;for(var c in this.handles)if(d(this.handles[c])[0]==b.target)a=true;return!this.options.disabled&&a},_mouseStart:function(b){var a=this.options,c=this.element.position(),
+e=this.element;this.resizing=true;this.documentScroll={top:d(document).scrollTop(),left:d(document).scrollLeft()};if(e.is(".ui-draggable")||/absolute/.test(e.css("position")))e.css({position:"absolute",top:c.top,left:c.left});d.browser.opera&&/relative/.test(e.css("position"))&&e.css({position:"relative",top:"auto",left:"auto"});this._renderProxy();c=m(this.helper.css("left"));var g=m(this.helper.css("top"));if(a.containment){c+=d(a.containment).scrollLeft()||0;g+=d(a.containment).scrollTop()||0}this.offset=
+this.helper.offset();this.position={left:c,top:g};this.size=this._helper?{width:e.outerWidth(),height:e.outerHeight()}:{width:e.width(),height:e.height()};this.originalSize=this._helper?{width:e.outerWidth(),height:e.outerHeight()}:{width:e.width(),height:e.height()};this.originalPosition={left:c,top:g};this.sizeDiff={width:e.outerWidth()-e.width(),height:e.outerHeight()-e.height()};this.originalMousePosition={left:b.pageX,top:b.pageY};this.aspectRatio=typeof a.aspectRatio=="number"?a.aspectRatio:
+this.originalSize.width/this.originalSize.height||1;a=d(".ui-resizable-"+this.axis).css("cursor");d("body").css("cursor",a=="auto"?this.axis+"-resize":a);e.addClass("ui-resizable-resizing");this._propagate("start",b);return true},_mouseDrag:function(b){var a=this.helper,c=this.originalMousePosition,e=this._change[this.axis];if(!e)return false;c=e.apply(this,[b,b.pageX-c.left||0,b.pageY-c.top||0]);if(this._aspectRatio||b.shiftKey)c=this._updateRatio(c,b);c=this._respectSize(c,b);this._propagate("resize",
+b);a.css({top:this.position.top+"px",left:this.position.left+"px",width:this.size.width+"px",height:this.size.height+"px"});!this._helper&&this._proportionallyResizeElements.length&&this._proportionallyResize();this._updateCache(c);this._trigger("resize",b,this.ui());return false},_mouseStop:function(b){this.resizing=false;var a=this.options,c=this;if(this._helper){var e=this._proportionallyResizeElements,g=e.length&&/textarea/i.test(e[0].nodeName);e=g&&d.ui.hasScroll(e[0],"left")?0:c.sizeDiff.height;
+g={width:c.size.width-(g?0:c.sizeDiff.width),height:c.size.height-e};e=parseInt(c.element.css("left"),10)+(c.position.left-c.originalPosition.left)||null;var f=parseInt(c.element.css("top"),10)+(c.position.top-c.originalPosition.top)||null;a.animate||this.element.css(d.extend(g,{top:f,left:e}));c.helper.height(c.size.height);c.helper.width(c.size.width);this._helper&&!a.animate&&this._proportionallyResize()}d("body").css("cursor","auto");this.element.removeClass("ui-resizable-resizing");this._propagate("stop",
+b);this._helper&&this.helper.remove();return false},_updateCache:function(b){this.offset=this.helper.offset();if(k(b.left))this.position.left=b.left;if(k(b.top))this.position.top=b.top;if(k(b.height))this.size.height=b.height;if(k(b.width))this.size.width=b.width},_updateRatio:function(b){var a=this.position,c=this.size,e=this.axis;if(b.height)b.width=c.height*this.aspectRatio;else if(b.width)b.height=c.width/this.aspectRatio;if(e=="sw"){b.left=a.left+(c.width-b.width);b.top=null}if(e=="nw"){b.top=
+a.top+(c.height-b.height);b.left=a.left+(c.width-b.width)}return b},_respectSize:function(b){var a=this.options,c=this.axis,e=k(b.width)&&a.maxWidth&&a.maxWidth<b.width,g=k(b.height)&&a.maxHeight&&a.maxHeight<b.height,f=k(b.width)&&a.minWidth&&a.minWidth>b.width,h=k(b.height)&&a.minHeight&&a.minHeight>b.height;if(f)b.width=a.minWidth;if(h)b.height=a.minHeight;if(e)b.width=a.maxWidth;if(g)b.height=a.maxHeight;var i=this.originalPosition.left+this.originalSize.width,j=this.position.top+this.size.height,
+l=/sw|nw|w/.test(c);c=/nw|ne|n/.test(c);if(f&&l)b.left=i-a.minWidth;if(e&&l)b.left=i-a.maxWidth;if(h&&c)b.top=j-a.minHeight;if(g&&c)b.top=j-a.maxHeight;if((a=!b.width&&!b.height)&&!b.left&&b.top)b.top=null;else if(a&&!b.top&&b.left)b.left=null;return b},_proportionallyResize:function(){if(this._proportionallyResizeElements.length)for(var b=this.helper||this.element,a=0;a<this._proportionallyResizeElements.length;a++){var c=this._proportionallyResizeElements[a];if(!this.borderDif){var e=[c.css("borderTopWidth"),
+c.css("borderRightWidth"),c.css("borderBottomWidth"),c.css("borderLeftWidth")],g=[c.css("paddingTop"),c.css("paddingRight"),c.css("paddingBottom"),c.css("paddingLeft")];this.borderDif=d.map(e,function(f,h){f=parseInt(f,10)||0;h=parseInt(g[h],10)||0;return f+h})}d.browser.msie&&(d(b).is(":hidden")||d(b).parents(":hidden").length)||c.css({height:b.height()-this.borderDif[0]-this.borderDif[2]||0,width:b.width()-this.borderDif[1]-this.borderDif[3]||0})}},_renderProxy:function(){var b=this.options;this.elementOffset=
+this.element.offset();if(this._helper){this.helper=this.helper||d('<div style="overflow:hidden;"></div>');var a=d.browser.msie&&d.browser.version<7,c=a?1:0;a=a?2:-1;this.helper.addClass(this._helper).css({width:this.element.outerWidth()+a,height:this.element.outerHeight()+a,position:"absolute",left:this.elementOffset.left-c+"px",top:this.elementOffset.top-c+"px",zIndex:++b.zIndex});this.helper.appendTo("body").disableSelection()}else this.helper=this.element},_change:{e:function(b,a){return{width:this.originalSize.width+
+a}},w:function(b,a){return{left:this.originalPosition.left+a,width:this.originalSize.width-a}},n:function(b,a,c){return{top:this.originalPosition.top+c,height:this.originalSize.height-c}},s:function(b,a,c){return{height:this.originalSize.height+c}},se:function(b,a,c){return d.extend(this._change.s.apply(this,arguments),this._change.e.apply(this,[b,a,c]))},sw:function(b,a,c){return d.extend(this._change.s.apply(this,arguments),this._change.w.apply(this,[b,a,c]))},ne:function(b,a,c){return d.extend(this._change.n.apply(this,
+arguments),this._change.e.apply(this,[b,a,c]))},nw:function(b,a,c){return d.extend(this._change.n.apply(this,arguments),this._change.w.apply(this,[b,a,c]))}},_propagate:function(b,a){d.ui.plugin.call(this,b,[a,this.ui()]);b!="resize"&&this._trigger(b,a,this.ui())},plugins:{},ui:function(){return{originalElement:this.originalElement,element:this.element,helper:this.helper,position:this.position,size:this.size,originalSize:this.originalSize,originalPosition:this.originalPosition}}});d.extend(d.ui.resizable,
+{version:"1.8.2"});d.ui.plugin.add("resizable","alsoResize",{start:function(){var b=d(this).data("resizable").options,a=function(c){d(c).each(function(){d(this).data("resizable-alsoresize",{width:parseInt(d(this).width(),10),height:parseInt(d(this).height(),10),left:parseInt(d(this).css("left"),10),top:parseInt(d(this).css("top"),10)})})};if(typeof b.alsoResize=="object"&&!b.alsoResize.parentNode)if(b.alsoResize.length){b.alsoResize=b.alsoResize[0];a(b.alsoResize)}else d.each(b.alsoResize,function(c){a(c)});
+else a(b.alsoResize)},resize:function(){var b=d(this).data("resizable"),a=b.options,c=b.originalSize,e=b.originalPosition,g={height:b.size.height-c.height||0,width:b.size.width-c.width||0,top:b.position.top-e.top||0,left:b.position.left-e.left||0},f=function(h,i){d(h).each(function(){var j=d(this),l=d(this).data("resizable-alsoresize"),p={};d.each((i&&i.length?i:["width","height","top","left"])||["width","height","top","left"],function(n,o){if((n=(l[o]||0)+(g[o]||0))&&n>=0)p[o]=n||null});if(/relative/.test(j.css("position"))&&
+d.browser.opera){b._revertToRelativePosition=true;j.css({position:"absolute",top:"auto",left:"auto"})}j.css(p)})};typeof a.alsoResize=="object"&&!a.alsoResize.nodeType?d.each(a.alsoResize,function(h,i){f(h,i)}):f(a.alsoResize)},stop:function(){var b=d(this).data("resizable");if(b._revertToRelativePosition&&d.browser.opera){b._revertToRelativePosition=false;el.css({position:"relative"})}d(this).removeData("resizable-alsoresize-start")}});d.ui.plugin.add("resizable","animate",{stop:function(b){var a=
+d(this).data("resizable"),c=a.options,e=a._proportionallyResizeElements,g=e.length&&/textarea/i.test(e[0].nodeName),f=g&&d.ui.hasScroll(e[0],"left")?0:a.sizeDiff.height;g={width:a.size.width-(g?0:a.sizeDiff.width),height:a.size.height-f};f=parseInt(a.element.css("left"),10)+(a.position.left-a.originalPosition.left)||null;var h=parseInt(a.element.css("top"),10)+(a.position.top-a.originalPosition.top)||null;a.element.animate(d.extend(g,h&&f?{top:h,left:f}:{}),{duration:c.animateDuration,easing:c.animateEasing,
+step:function(){var i={width:parseInt(a.element.css("width"),10),height:parseInt(a.element.css("height"),10),top:parseInt(a.element.css("top"),10),left:parseInt(a.element.css("left"),10)};e&&e.length&&d(e[0]).css({width:i.width,height:i.height});a._updateCache(i);a._propagate("resize",b)}})}});d.ui.plugin.add("resizable","containment",{start:function(){var b=d(this).data("resizable"),a=b.element,c=b.options.containment;if(a=c instanceof d?c.get(0):/parent/.test(c)?a.parent().get(0):c){b.containerElement=
+d(a);if(/document/.test(c)||c==document){b.containerOffset={left:0,top:0};b.containerPosition={left:0,top:0};b.parentData={element:d(document),left:0,top:0,width:d(document).width(),height:d(document).height()||document.body.parentNode.scrollHeight}}else{var e=d(a),g=[];d(["Top","Right","Left","Bottom"]).each(function(i,j){g[i]=m(e.css("padding"+j))});b.containerOffset=e.offset();b.containerPosition=e.position();b.containerSize={height:e.innerHeight()-g[3],width:e.innerWidth()-g[1]};c=b.containerOffset;
+var f=b.containerSize.height,h=b.containerSize.width;h=d.ui.hasScroll(a,"left")?a.scrollWidth:h;f=d.ui.hasScroll(a)?a.scrollHeight:f;b.parentData={element:a,left:c.left,top:c.top,width:h,height:f}}}},resize:function(b){var a=d(this).data("resizable"),c=a.options,e=a.containerOffset,g=a.position;b=a._aspectRatio||b.shiftKey;var f={top:0,left:0},h=a.containerElement;if(h[0]!=document&&/static/.test(h.css("position")))f=e;if(g.left<(a._helper?e.left:0)){a.size.width+=a._helper?a.position.left-e.left:
+a.position.left-f.left;if(b)a.size.height=a.size.width/c.aspectRatio;a.position.left=c.helper?e.left:0}if(g.top<(a._helper?e.top:0)){a.size.height+=a._helper?a.position.top-e.top:a.position.top;if(b)a.size.width=a.size.height*c.aspectRatio;a.position.top=a._helper?e.top:0}a.offset.left=a.parentData.left+a.position.left;a.offset.top=a.parentData.top+a.position.top;c=Math.abs((a._helper?a.offset.left-f.left:a.offset.left-f.left)+a.sizeDiff.width);e=Math.abs((a._helper?a.offset.top-f.top:a.offset.top-
+e.top)+a.sizeDiff.height);g=a.containerElement.get(0)==a.element.parent().get(0);f=/relative|absolute/.test(a.containerElement.css("position"));if(g&&f)c-=a.parentData.left;if(c+a.size.width>=a.parentData.width){a.size.width=a.parentData.width-c;if(b)a.size.height=a.size.width/a.aspectRatio}if(e+a.size.height>=a.parentData.height){a.size.height=a.parentData.height-e;if(b)a.size.width=a.size.height*a.aspectRatio}},stop:function(){var b=d(this).data("resizable"),a=b.options,c=b.containerOffset,e=b.containerPosition,
+g=b.containerElement,f=d(b.helper),h=f.offset(),i=f.outerWidth()-b.sizeDiff.width;f=f.outerHeight()-b.sizeDiff.height;b._helper&&!a.animate&&/relative/.test(g.css("position"))&&d(this).css({left:h.left-e.left-c.left,width:i,height:f});b._helper&&!a.animate&&/static/.test(g.css("position"))&&d(this).css({left:h.left-e.left-c.left,width:i,height:f})}});d.ui.plugin.add("resizable","ghost",{start:function(){var b=d(this).data("resizable"),a=b.options,c=b.size;b.ghost=b.originalElement.clone();b.ghost.css({opacity:0.25,
+display:"block",position:"relative",height:c.height,width:c.width,margin:0,left:0,top:0}).addClass("ui-resizable-ghost").addClass(typeof a.ghost=="string"?a.ghost:"");b.ghost.appendTo(b.helper)},resize:function(){var b=d(this).data("resizable");b.ghost&&b.ghost.css({position:"relative",height:b.size.height,width:b.size.width})},stop:function(){var b=d(this).data("resizable");b.ghost&&b.helper&&b.helper.get(0).removeChild(b.ghost.get(0))}});d.ui.plugin.add("resizable","grid",{resize:function(){var b=
+d(this).data("resizable"),a=b.options,c=b.size,e=b.originalSize,g=b.originalPosition,f=b.axis;a.grid=typeof a.grid=="number"?[a.grid,a.grid]:a.grid;var h=Math.round((c.width-e.width)/(a.grid[0]||1))*(a.grid[0]||1);a=Math.round((c.height-e.height)/(a.grid[1]||1))*(a.grid[1]||1);if(/^(se|s|e)$/.test(f)){b.size.width=e.width+h;b.size.height=e.height+a}else if(/^(ne)$/.test(f)){b.size.width=e.width+h;b.size.height=e.height+a;b.position.top=g.top-a}else{if(/^(sw)$/.test(f)){b.size.width=e.width+h;b.size.height=
+e.height+a}else{b.size.width=e.width+h;b.size.height=e.height+a;b.position.top=g.top-a}b.position.left=g.left-h}}});var m=function(b){return parseInt(b,10)||0},k=function(b){return!isNaN(parseInt(b,10))}})(jQuery);
+;
+/*
+ * jQuery UI Selectable 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Selectables
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.mouse.js
+ *     jquery.ui.widget.js
+ */
+(function($) {
+
+$.widget("ui.selectable", $.ui.mouse, {
+       options: {
+               appendTo: 'body',
+               autoRefresh: true,
+               distance: 0,
+               filter: '*',
+               tolerance: 'touch'
+       },
+       _create: function() {
+               var self = this;
+
+               this.element.addClass("ui-selectable");
+
+               this.dragged = false;
+
+               // cache selectee children based on filter
+               var selectees;
+               this.refresh = function() {
+                       selectees = $(self.options.filter, self.element[0]);
+                       selectees.each(function() {
+                               var $this = $(this);
+                               var pos = $this.offset();
+                               $.data(this, "selectable-item", {
+                                       element: this,
+                                       $element: $this,
+                                       left: pos.left,
+                                       top: pos.top,
+                                       right: pos.left + $this.outerWidth(),
+                                       bottom: pos.top + $this.outerHeight(),
+                                       startselected: false,
+                                       selected: $this.hasClass('ui-selected'),
+                                       selecting: $this.hasClass('ui-selecting'),
+                                       unselecting: $this.hasClass('ui-unselecting')
+                               });
+                       });
+               };
+               this.refresh();
+
+               this.selectees = selectees.addClass("ui-selectee");
+
+               this._mouseInit();
+
+               this.helper = $("<div class='ui-selectable-helper'></div>");
+       },
+
+       destroy: function() {
+               this.selectees
+                       .removeClass("ui-selectee")
+                       .removeData("selectable-item");
+               this.element
+                       .removeClass("ui-selectable ui-selectable-disabled")
+                       .removeData("selectable")
+                       .unbind(".selectable");
+               this._mouseDestroy();
+
+               return this;
+       },
+
+       _mouseStart: function(event) {
+               var self = this;
+
+               this.opos = [event.pageX, event.pageY];
+
+               if (this.options.disabled)
+                       return;
+
+               var options = this.options;
+
+               this.selectees = $(options.filter, this.element[0]);
+
+               this._trigger("start", event);
+
+               $(options.appendTo).append(this.helper);
+               // position helper (lasso)
+               this.helper.css({
+                       "z-index": 100,
+                       "position": "absolute",
+                       "left": event.clientX,
+                       "top": event.clientY,
+                       "width": 0,
+                       "height": 0
+               });
+
+               if (options.autoRefresh) {
+                       this.refresh();
+               }
+
+               this.selectees.filter('.ui-selected').each(function() {
+                       var selectee = $.data(this, "selectable-item");
+                       selectee.startselected = true;
+                       if (!event.metaKey) {
+                               selectee.$element.removeClass('ui-selected');
+                               selectee.selected = false;
+                               selectee.$element.addClass('ui-unselecting');
+                               selectee.unselecting = true;
+                               // selectable UNSELECTING callback
+                               self._trigger("unselecting", event, {
+                                       unselecting: selectee.element
+                               });
+                       }
+               });
+
+               $(event.target).parents().andSelf().each(function() {
+                       var selectee = $.data(this, "selectable-item");
+                       if (selectee) {
+                               var doSelect = !event.metaKey || !selectee.$element.hasClass('ui-selected');
+                               selectee.$element
+                                       .removeClass(doSelect ? "ui-unselecting" : "ui-selected")
+                                       .addClass(doSelect ? "ui-selecting" : "ui-unselecting");
+                               selectee.unselecting = !doSelect;
+                               selectee.selecting = doSelect;
+                               selectee.selected = doSelect;
+                               // selectable (UN)SELECTING callback
+                               if (doSelect) {
+                                       self._trigger("selecting", event, {
+                                               selecting: selectee.element
+                                       });
+                               } else {
+                                       self._trigger("unselecting", event, {
+                                               unselecting: selectee.element
+                                       });
+                               }
+                               return false;
+                       }
+               });
+
+       },
+
+       _mouseDrag: function(event) {
+               var self = this;
+               this.dragged = true;
+
+               if (this.options.disabled)
+                       return;
+
+               var options = this.options;
+
+               var x1 = this.opos[0], y1 = this.opos[1], x2 = event.pageX, y2 = event.pageY;
+               if (x1 > x2) { var tmp = x2; x2 = x1; x1 = tmp; }
+               if (y1 > y2) { var tmp = y2; y2 = y1; y1 = tmp; }
+               this.helper.css({left: x1, top: y1, width: x2-x1, height: y2-y1});
+
+               this.selectees.each(function() {
+                       var selectee = $.data(this, "selectable-item");
+                       //prevent helper from being selected if appendTo: selectable
+                       if (!selectee || selectee.element == self.element[0])
+                               return;
+                       var hit = false;
+                       if (options.tolerance == 'touch') {
+                               hit = ( !(selectee.left > x2 || selectee.right < x1 || selectee.top > y2 || selectee.bottom < y1) );
+                       } else if (options.tolerance == 'fit') {
+                               hit = (selectee.left > x1 && selectee.right < x2 && selectee.top > y1 && selectee.bottom < y2);
+                       }
+
+                       if (hit) {
+                               // SELECT
+                               if (selectee.selected) {
+                                       selectee.$element.removeClass('ui-selected');
+                                       selectee.selected = false;
+                               }
+                               if (selectee.unselecting) {
+                                       selectee.$element.removeClass('ui-unselecting');
+                                       selectee.unselecting = false;
+                               }
+                               if (!selectee.selecting) {
+                                       selectee.$element.addClass('ui-selecting');
+                                       selectee.selecting = true;
+                                       // selectable SELECTING callback
+                                       self._trigger("selecting", event, {
+                                               selecting: selectee.element
+                                       });
+                               }
+                       } else {
+                               // UNSELECT
+                               if (selectee.selecting) {
+                                       if (event.metaKey && selectee.startselected) {
+                                               selectee.$element.removeClass('ui-selecting');
+                                               selectee.selecting = false;
+                                               selectee.$element.addClass('ui-selected');
+                                               selectee.selected = true;
+                                       } else {
+                                               selectee.$element.removeClass('ui-selecting');
+                                               selectee.selecting = false;
+                                               if (selectee.startselected) {
+                                                       selectee.$element.addClass('ui-unselecting');
+                                                       selectee.unselecting = true;
+                                               }
+                                               // selectable UNSELECTING callback
+                                               self._trigger("unselecting", event, {
+                                                       unselecting: selectee.element
+                                               });
+                                       }
+                               }
+                               if (selectee.selected) {
+                                       if (!event.metaKey && !selectee.startselected) {
+                                               selectee.$element.removeClass('ui-selected');
+                                               selectee.selected = false;
+
+                                               selectee.$element.addClass('ui-unselecting');
+                                               selectee.unselecting = true;
+                                               // selectable UNSELECTING callback
+                                               self._trigger("unselecting", event, {
+                                                       unselecting: selectee.element
+                                               });
+                                       }
+                               }
+                       }
+               });
+
+               return false;
+       },
+
+       _mouseStop: function(event) {
+               var self = this;
+
+               this.dragged = false;
+
+               var options = this.options;
+
+               $('.ui-unselecting', this.element[0]).each(function() {
+                       var selectee = $.data(this, "selectable-item");
+                       selectee.$element.removeClass('ui-unselecting');
+                       selectee.unselecting = false;
+                       selectee.startselected = false;
+                       self._trigger("unselected", event, {
+                               unselected: selectee.element
+                       });
+               });
+               $('.ui-selecting', this.element[0]).each(function() {
+                       var selectee = $.data(this, "selectable-item");
+                       selectee.$element.removeClass('ui-selecting').addClass('ui-selected');
+                       selectee.selecting = false;
+                       selectee.selected = true;
+                       selectee.startselected = true;
+                       self._trigger("selected", event, {
+                               selected: selectee.element
+                       });
+               });
+               this._trigger("stop", event);
+
+               this.helper.remove();
+
+               return false;
+       }
+
+});
+
+$.extend($.ui.selectable, {
+       version: "1.8.2"
+});
+
+})(jQuery);
+(function(e){e.widget("ui.selectable",e.ui.mouse,{options:{appendTo:"body",autoRefresh:true,distance:0,filter:"*",tolerance:"touch"},_create:function(){var c=this;this.element.addClass("ui-selectable");this.dragged=false;var f;this.refresh=function(){f=e(c.options.filter,c.element[0]);f.each(function(){var d=e(this),b=d.offset();e.data(this,"selectable-item",{element:this,$element:d,left:b.left,top:b.top,right:b.left+d.outerWidth(),bottom:b.top+d.outerHeight(),startselected:false,selected:d.hasClass("ui-selected"),
+selecting:d.hasClass("ui-selecting"),unselecting:d.hasClass("ui-unselecting")})})};this.refresh();this.selectees=f.addClass("ui-selectee");this._mouseInit();this.helper=e("<div class='ui-selectable-helper'></div>")},destroy:function(){this.selectees.removeClass("ui-selectee").removeData("selectable-item");this.element.removeClass("ui-selectable ui-selectable-disabled").removeData("selectable").unbind(".selectable");this._mouseDestroy();return this},_mouseStart:function(c){var f=this;this.opos=[c.pageX,
+c.pageY];if(!this.options.disabled){var d=this.options;this.selectees=e(d.filter,this.element[0]);this._trigger("start",c);e(d.appendTo).append(this.helper);this.helper.css({"z-index":100,position:"absolute",left:c.clientX,top:c.clientY,width:0,height:0});d.autoRefresh&&this.refresh();this.selectees.filter(".ui-selected").each(function(){var b=e.data(this,"selectable-item");b.startselected=true;if(!c.metaKey){b.$element.removeClass("ui-selected");b.selected=false;b.$element.addClass("ui-unselecting");
+b.unselecting=true;f._trigger("unselecting",c,{unselecting:b.element})}});e(c.target).parents().andSelf().each(function(){var b=e.data(this,"selectable-item");if(b){var g=!c.metaKey||!b.$element.hasClass("ui-selected");b.$element.removeClass(g?"ui-unselecting":"ui-selected").addClass(g?"ui-selecting":"ui-unselecting");b.unselecting=!g;b.selecting=g;(b.selected=g)?f._trigger("selecting",c,{selecting:b.element}):f._trigger("unselecting",c,{unselecting:b.element});return false}})}},_mouseDrag:function(c){var f=
+this;this.dragged=true;if(!this.options.disabled){var d=this.options,b=this.opos[0],g=this.opos[1],h=c.pageX,i=c.pageY;if(b>h){var j=h;h=b;b=j}if(g>i){j=i;i=g;g=j}this.helper.css({left:b,top:g,width:h-b,height:i-g});this.selectees.each(function(){var a=e.data(this,"selectable-item");if(!(!a||a.element==f.element[0])){var k=false;if(d.tolerance=="touch")k=!(a.left>h||a.right<b||a.top>i||a.bottom<g);else if(d.tolerance=="fit")k=a.left>b&&a.right<h&&a.top>g&&a.bottom<i;if(k){if(a.selected){a.$element.removeClass("ui-selected");
+a.selected=false}if(a.unselecting){a.$element.removeClass("ui-unselecting");a.unselecting=false}if(!a.selecting){a.$element.addClass("ui-selecting");a.selecting=true;f._trigger("selecting",c,{selecting:a.element})}}else{if(a.selecting)if(c.metaKey&&a.startselected){a.$element.removeClass("ui-selecting");a.selecting=false;a.$element.addClass("ui-selected");a.selected=true}else{a.$element.removeClass("ui-selecting");a.selecting=false;if(a.startselected){a.$element.addClass("ui-unselecting");a.unselecting=
+true}f._trigger("unselecting",c,{unselecting:a.element})}if(a.selected)if(!c.metaKey&&!a.startselected){a.$element.removeClass("ui-selected");a.selected=false;a.$element.addClass("ui-unselecting");a.unselecting=true;f._trigger("unselecting",c,{unselecting:a.element})}}}});return false}},_mouseStop:function(c){var f=this;this.dragged=false;e(".ui-unselecting",this.element[0]).each(function(){var d=e.data(this,"selectable-item");d.$element.removeClass("ui-unselecting");d.unselecting=false;d.startselected=
+false;f._trigger("unselected",c,{unselected:d.element})});e(".ui-selecting",this.element[0]).each(function(){var d=e.data(this,"selectable-item");d.$element.removeClass("ui-selecting").addClass("ui-selected");d.selecting=false;d.selected=true;d.startselected=true;f._trigger("selected",c,{selected:d.element})});this._trigger("stop",c);this.helper.remove();return false}});e.extend(e.ui.selectable,{version:"1.8.2"})})(jQuery);
+;/*
+ * jQuery UI Sortable 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Sortables
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.mouse.js
+ *     jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.sortable",d.ui.mouse,{widgetEventPrefix:"sort",options:{appendTo:"parent",axis:false,connectWith:false,containment:false,cursor:"auto",cursorAt:false,dropOnEmpty:true,forcePlaceholderSize:false,forceHelperSize:false,grid:false,handle:false,helper:"original",items:"> *",opacity:false,placeholder:false,revert:false,scroll:true,scrollSensitivity:20,scrollSpeed:20,scope:"default",tolerance:"intersect",zIndex:1E3},_create:function(){this.containerCache={};this.element.addClass("ui-sortable");
+this.refresh();this.floating=this.items.length?/left|right/.test(this.items[0].item.css("float")):false;this.offset=this.element.offset();this._mouseInit()},destroy:function(){this.element.removeClass("ui-sortable ui-sortable-disabled").removeData("sortable").unbind(".sortable");this._mouseDestroy();for(var a=this.items.length-1;a>=0;a--)this.items[a].item.removeData("sortable-item");return this},_setOption:function(a,b){if(a==="disabled"){this.options[a]=b;this.widget()[b?"addClass":"removeClass"]("ui-sortable-disabled")}else d.Widget.prototype._setOption.apply(this,
+arguments)},_mouseCapture:function(a,b){if(this.reverting)return false;if(this.options.disabled||this.options.type=="static")return false;this._refreshItems(a);var c=null,e=this;d(a.target).parents().each(function(){if(d.data(this,"sortable-item")==e){c=d(this);return false}});if(d.data(a.target,"sortable-item")==e)c=d(a.target);if(!c)return false;if(this.options.handle&&!b){var f=false;d(this.options.handle,c).find("*").andSelf().each(function(){if(this==a.target)f=true});if(!f)return false}this.currentItem=
+c;this._removeCurrentsFromItems();return true},_mouseStart:function(a,b,c){b=this.options;var e=this;this.currentContainer=this;this.refreshPositions();this.helper=this._createHelper(a);this._cacheHelperProportions();this._cacheMargins();this.scrollParent=this.helper.scrollParent();this.offset=this.currentItem.offset();this.offset={top:this.offset.top-this.margins.top,left:this.offset.left-this.margins.left};this.helper.css("position","absolute");this.cssPosition=this.helper.css("position");d.extend(this.offset,
+{click:{left:a.pageX-this.offset.left,top:a.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()});this.originalPosition=this._generatePosition(a);this.originalPageX=a.pageX;this.originalPageY=a.pageY;b.cursorAt&&this._adjustOffsetFromHelper(b.cursorAt);this.domPosition={prev:this.currentItem.prev()[0],parent:this.currentItem.parent()[0]};this.helper[0]!=this.currentItem[0]&&this.currentItem.hide();this._createPlaceholder();b.containment&&this._setContainment();
+if(b.cursor){if(d("body").css("cursor"))this._storedCursor=d("body").css("cursor");d("body").css("cursor",b.cursor)}if(b.opacity){if(this.helper.css("opacity"))this._storedOpacity=this.helper.css("opacity");this.helper.css("opacity",b.opacity)}if(b.zIndex){if(this.helper.css("zIndex"))this._storedZIndex=this.helper.css("zIndex");this.helper.css("zIndex",b.zIndex)}if(this.scrollParent[0]!=document&&this.scrollParent[0].tagName!="HTML")this.overflowOffset=this.scrollParent.offset();this._trigger("start",
+a,this._uiHash());this._preserveHelperProportions||this._cacheHelperProportions();if(!c)for(c=this.containers.length-1;c>=0;c--)this.containers[c]._trigger("activate",a,e._uiHash(this));if(d.ui.ddmanager)d.ui.ddmanager.current=this;d.ui.ddmanager&&!b.dropBehaviour&&d.ui.ddmanager.prepareOffsets(this,a);this.dragging=true;this.helper.addClass("ui-sortable-helper");this._mouseDrag(a);return true},_mouseDrag:function(a){this.position=this._generatePosition(a);this.positionAbs=this._convertPositionTo("absolute");
+if(!this.lastPositionAbs)this.lastPositionAbs=this.positionAbs;if(this.options.scroll){var b=this.options,c=false;if(this.scrollParent[0]!=document&&this.scrollParent[0].tagName!="HTML"){if(this.overflowOffset.top+this.scrollParent[0].offsetHeight-a.pageY<b.scrollSensitivity)this.scrollParent[0].scrollTop=c=this.scrollParent[0].scrollTop+b.scrollSpeed;else if(a.pageY-this.overflowOffset.top<b.scrollSensitivity)this.scrollParent[0].scrollTop=c=this.scrollParent[0].scrollTop-b.scrollSpeed;if(this.overflowOffset.left+
+this.scrollParent[0].offsetWidth-a.pageX<b.scrollSensitivity)this.scrollParent[0].scrollLeft=c=this.scrollParent[0].scrollLeft+b.scrollSpeed;else if(a.pageX-this.overflowOffset.left<b.scrollSensitivity)this.scrollParent[0].scrollLeft=c=this.scrollParent[0].scrollLeft-b.scrollSpeed}else{if(a.pageY-d(document).scrollTop()<b.scrollSensitivity)c=d(document).scrollTop(d(document).scrollTop()-b.scrollSpeed);else if(d(window).height()-(a.pageY-d(document).scrollTop())<b.scrollSensitivity)c=d(document).scrollTop(d(document).scrollTop()+
+b.scrollSpeed);if(a.pageX-d(document).scrollLeft()<b.scrollSensitivity)c=d(document).scrollLeft(d(document).scrollLeft()-b.scrollSpeed);else if(d(window).width()-(a.pageX-d(document).scrollLeft())<b.scrollSensitivity)c=d(document).scrollLeft(d(document).scrollLeft()+b.scrollSpeed)}c!==false&&d.ui.ddmanager&&!b.dropBehaviour&&d.ui.ddmanager.prepareOffsets(this,a)}this.positionAbs=this._convertPositionTo("absolute");if(!this.options.axis||this.options.axis!="y")this.helper[0].style.left=this.position.left+
+"px";if(!this.options.axis||this.options.axis!="x")this.helper[0].style.top=this.position.top+"px";for(b=this.items.length-1;b>=0;b--){c=this.items[b];var e=c.item[0],f=this._intersectsWithPointer(c);if(f)if(e!=this.currentItem[0]&&this.placeholder[f==1?"next":"prev"]()[0]!=e&&!d.ui.contains(this.placeholder[0],e)&&(this.options.type=="semi-dynamic"?!d.ui.contains(this.element[0],e):true)){this.direction=f==1?"down":"up";if(this.options.tolerance=="pointer"||this._intersectsWithSides(c))this._rearrange(a,
+c);else break;this._trigger("change",a,this._uiHash());break}}this._contactContainers(a);d.ui.ddmanager&&d.ui.ddmanager.drag(this,a);this._trigger("sort",a,this._uiHash());this.lastPositionAbs=this.positionAbs;return false},_mouseStop:function(a,b){if(a){d.ui.ddmanager&&!this.options.dropBehaviour&&d.ui.ddmanager.drop(this,a);if(this.options.revert){var c=this;b=c.placeholder.offset();c.reverting=true;d(this.helper).animate({left:b.left-this.offset.parent.left-c.margins.left+(this.offsetParent[0]==
+document.body?0:this.offsetParent[0].scrollLeft),top:b.top-this.offset.parent.top-c.margins.top+(this.offsetParent[0]==document.body?0:this.offsetParent[0].scrollTop)},parseInt(this.options.revert,10)||500,function(){c._clear(a)})}else this._clear(a,b);return false}},cancel:function(){var a=this;if(this.dragging){this._mouseUp();this.options.helper=="original"?this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"):this.currentItem.show();for(var b=this.containers.length-1;b>=0;b--){this.containers[b]._trigger("deactivate",
+null,a._uiHash(this));if(this.containers[b].containerCache.over){this.containers[b]._trigger("out",null,a._uiHash(this));this.containers[b].containerCache.over=0}}}this.placeholder[0].parentNode&&this.placeholder[0].parentNode.removeChild(this.placeholder[0]);this.options.helper!="original"&&this.helper&&this.helper[0].parentNode&&this.helper.remove();d.extend(this,{helper:null,dragging:false,reverting:false,_noFinalSort:null});this.domPosition.prev?d(this.domPosition.prev).after(this.currentItem):
+d(this.domPosition.parent).prepend(this.currentItem);return this},serialize:function(a){var b=this._getItemsAsjQuery(a&&a.connected),c=[];a=a||{};d(b).each(function(){var e=(d(a.item||this).attr(a.attribute||"id")||"").match(a.expression||/(.+)[-=_](.+)/);if(e)c.push((a.key||e[1]+"[]")+"="+(a.key&&a.expression?e[1]:e[2]))});return c.join("&")},toArray:function(a){var b=this._getItemsAsjQuery(a&&a.connected),c=[];a=a||{};b.each(function(){c.push(d(a.item||this).attr(a.attribute||"id")||"")});return c},
+_intersectsWith:function(a){var b=this.positionAbs.left,c=b+this.helperProportions.width,e=this.positionAbs.top,f=e+this.helperProportions.height,g=a.left,h=g+a.width,i=a.top,k=i+a.height,j=this.offset.click.top,l=this.offset.click.left;j=e+j>i&&e+j<k&&b+l>g&&b+l<h;return this.options.tolerance=="pointer"||this.options.forcePointerForContainers||this.options.tolerance!="pointer"&&this.helperProportions[this.floating?"width":"height"]>a[this.floating?"width":"height"]?j:g<b+this.helperProportions.width/
+2&&c-this.helperProportions.width/2<h&&i<e+this.helperProportions.height/2&&f-this.helperProportions.height/2<k},_intersectsWithPointer:function(a){var b=d.ui.isOverAxis(this.positionAbs.top+this.offset.click.top,a.top,a.height);a=d.ui.isOverAxis(this.positionAbs.left+this.offset.click.left,a.left,a.width);b=b&&a;a=this._getDragVerticalDirection();var c=this._getDragHorizontalDirection();if(!b)return false;return this.floating?c&&c=="right"||a=="down"?2:1:a&&(a=="down"?2:1)},_intersectsWithSides:function(a){var b=
+d.ui.isOverAxis(this.positionAbs.top+this.offset.click.top,a.top+a.height/2,a.height);a=d.ui.isOverAxis(this.positionAbs.left+this.offset.click.left,a.left+a.width/2,a.width);var c=this._getDragVerticalDirection(),e=this._getDragHorizontalDirection();return this.floating&&e?e=="right"&&a||e=="left"&&!a:c&&(c=="down"&&b||c=="up"&&!b)},_getDragVerticalDirection:function(){var a=this.positionAbs.top-this.lastPositionAbs.top;return a!=0&&(a>0?"down":"up")},_getDragHorizontalDirection:function(){var a=
+this.positionAbs.left-this.lastPositionAbs.left;return a!=0&&(a>0?"right":"left")},refresh:function(a){this._refreshItems(a);this.refreshPositions();return this},_connectWith:function(){var a=this.options;return a.connectWith.constructor==String?[a.connectWith]:a.connectWith},_getItemsAsjQuery:function(a){var b=[],c=[],e=this._connectWith();if(e&&a)for(a=e.length-1;a>=0;a--)for(var f=d(e[a]),g=f.length-1;g>=0;g--){var h=d.data(f[g],"sortable");if(h&&h!=this&&!h.options.disabled)c.push([d.isFunction(h.options.items)?
+h.options.items.call(h.element):d(h.options.items,h.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),h])}c.push([d.isFunction(this.options.items)?this.options.items.call(this.element,null,{options:this.options,item:this.currentItem}):d(this.options.items,this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),this]);for(a=c.length-1;a>=0;a--)c[a][0].each(function(){b.push(this)});return d(b)},_removeCurrentsFromItems:function(){for(var a=this.currentItem.find(":data(sortable-item)"),
+b=0;b<this.items.length;b++)for(var c=0;c<a.length;c++)a[c]==this.items[b].item[0]&&this.items.splice(b,1)},_refreshItems:function(a){this.items=[];this.containers=[this];var b=this.items,c=[[d.isFunction(this.options.items)?this.options.items.call(this.element[0],a,{item:this.currentItem}):d(this.options.items,this.element),this]],e=this._connectWith();if(e)for(var f=e.length-1;f>=0;f--)for(var g=d(e[f]),h=g.length-1;h>=0;h--){var i=d.data(g[h],"sortable");if(i&&i!=this&&!i.options.disabled){c.push([d.isFunction(i.options.items)?
+i.options.items.call(i.element[0],a,{item:this.currentItem}):d(i.options.items,i.element),i]);this.containers.push(i)}}for(f=c.length-1;f>=0;f--){a=c[f][1];e=c[f][0];h=0;for(g=e.length;h<g;h++){i=d(e[h]);i.data("sortable-item",a);b.push({item:i,instance:a,width:0,height:0,left:0,top:0})}}},refreshPositions:function(a){if(this.offsetParent&&this.helper)this.offset.parent=this._getParentOffset();for(var b=this.items.length-1;b>=0;b--){var c=this.items[b],e=this.options.toleranceElement?d(this.options.toleranceElement,
+c.item):c.item;if(!a){c.width=e.outerWidth();c.height=e.outerHeight()}e=e.offset();c.left=e.left;c.top=e.top}if(this.options.custom&&this.options.custom.refreshContainers)this.options.custom.refreshContainers.call(this);else for(b=this.containers.length-1;b>=0;b--){e=this.containers[b].element.offset();this.containers[b].containerCache.left=e.left;this.containers[b].containerCache.top=e.top;this.containers[b].containerCache.width=this.containers[b].element.outerWidth();this.containers[b].containerCache.height=
+this.containers[b].element.outerHeight()}return this},_createPlaceholder:function(a){var b=a||this,c=b.options;if(!c.placeholder||c.placeholder.constructor==String){var e=c.placeholder;c.placeholder={element:function(){var f=d(document.createElement(b.currentItem[0].nodeName)).addClass(e||b.currentItem[0].className+" ui-sortable-placeholder").removeClass("ui-sortable-helper")[0];if(!e)f.style.visibility="hidden";return f},update:function(f,g){if(!(e&&!c.forcePlaceholderSize)){g.height()||g.height(b.currentItem.innerHeight()-
+parseInt(b.currentItem.css("paddingTop")||0,10)-parseInt(b.currentItem.css("paddingBottom")||0,10));g.width()||g.width(b.currentItem.innerWidth()-parseInt(b.currentItem.css("paddingLeft")||0,10)-parseInt(b.currentItem.css("paddingRight")||0,10))}}}}b.placeholder=d(c.placeholder.element.call(b.element,b.currentItem));b.currentItem.after(b.placeholder);c.placeholder.update(b,b.placeholder)},_contactContainers:function(a){for(var b=null,c=null,e=this.containers.length-1;e>=0;e--)if(!d.ui.contains(this.currentItem[0],
+this.containers[e].element[0]))if(this._intersectsWith(this.containers[e].containerCache)){if(!(b&&d.ui.contains(this.containers[e].element[0],b.element[0]))){b=this.containers[e];c=e}}else if(this.containers[e].containerCache.over){this.containers[e]._trigger("out",a,this._uiHash(this));this.containers[e].containerCache.over=0}if(b)if(this.containers.length===1){this.containers[c]._trigger("over",a,this._uiHash(this));this.containers[c].containerCache.over=1}else if(this.currentContainer!=this.containers[c]){b=
+1E4;e=null;for(var f=this.positionAbs[this.containers[c].floating?"left":"top"],g=this.items.length-1;g>=0;g--)if(d.ui.contains(this.containers[c].element[0],this.items[g].item[0])){var h=this.items[g][this.containers[c].floating?"left":"top"];if(Math.abs(h-f)<b){b=Math.abs(h-f);e=this.items[g]}}if(e||this.options.dropOnEmpty){this.currentContainer=this.containers[c];e?this._rearrange(a,e,null,true):this._rearrange(a,null,this.containers[c].element,true);this._trigger("change",a,this._uiHash());this.containers[c]._trigger("change",
+a,this._uiHash(this));this.options.placeholder.update(this.currentContainer,this.placeholder);this.containers[c]._trigger("over",a,this._uiHash(this));this.containers[c].containerCache.over=1}}},_createHelper:function(a){var b=this.options;a=d.isFunction(b.helper)?d(b.helper.apply(this.element[0],[a,this.currentItem])):b.helper=="clone"?this.currentItem.clone():this.currentItem;a.parents("body").length||d(b.appendTo!="parent"?b.appendTo:this.currentItem[0].parentNode)[0].appendChild(a[0]);if(a[0]==
+this.currentItem[0])this._storedCSS={width:this.currentItem[0].style.width,height:this.currentItem[0].style.height,position:this.currentItem.css("position"),top:this.currentItem.css("top"),left:this.currentItem.css("left")};if(a[0].style.width==""||b.forceHelperSize)a.width(this.currentItem.width());if(a[0].style.height==""||b.forceHelperSize)a.height(this.currentItem.height());return a},_adjustOffsetFromHelper:function(a){if(typeof a=="string")a=a.split(" ");if(d.isArray(a))a={left:+a[0],top:+a[1]||
+0};if("left"in a)this.offset.click.left=a.left+this.margins.left;if("right"in a)this.offset.click.left=this.helperProportions.width-a.right+this.margins.left;if("top"in a)this.offset.click.top=a.top+this.margins.top;if("bottom"in a)this.offset.click.top=this.helperProportions.height-a.bottom+this.margins.top},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var a=this.offsetParent.offset();if(this.cssPosition=="absolute"&&this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],
+this.offsetParent[0])){a.left+=this.scrollParent.scrollLeft();a.top+=this.scrollParent.scrollTop()}if(this.offsetParent[0]==document.body||this.offsetParent[0].tagName&&this.offsetParent[0].tagName.toLowerCase()=="html"&&d.browser.msie)a={top:0,left:0};return{top:a.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:a.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if(this.cssPosition=="relative"){var a=this.currentItem.position();return{top:a.top-
+(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:a.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}else return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.currentItem.css("marginLeft"),10)||0,top:parseInt(this.currentItem.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var a=this.options;
+if(a.containment=="parent")a.containment=this.helper[0].parentNode;if(a.containment=="document"||a.containment=="window")this.containment=[0-this.offset.relative.left-this.offset.parent.left,0-this.offset.relative.top-this.offset.parent.top,d(a.containment=="document"?document:window).width()-this.helperProportions.width-this.margins.left,(d(a.containment=="document"?document:window).height()||document.body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top];if(!/^(document|window|parent)$/.test(a.containment)){var b=
+d(a.containment)[0];a=d(a.containment).offset();var c=d(b).css("overflow")!="hidden";this.containment=[a.left+(parseInt(d(b).css("borderLeftWidth"),10)||0)+(parseInt(d(b).css("paddingLeft"),10)||0)-this.margins.left,a.top+(parseInt(d(b).css("borderTopWidth"),10)||0)+(parseInt(d(b).css("paddingTop"),10)||0)-this.margins.top,a.left+(c?Math.max(b.scrollWidth,b.offsetWidth):b.offsetWidth)-(parseInt(d(b).css("borderLeftWidth"),10)||0)-(parseInt(d(b).css("paddingRight"),10)||0)-this.helperProportions.width-
+this.margins.left,a.top+(c?Math.max(b.scrollHeight,b.offsetHeight):b.offsetHeight)-(parseInt(d(b).css("borderTopWidth"),10)||0)-(parseInt(d(b).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top]}},_convertPositionTo:function(a,b){if(!b)b=this.position;a=a=="absolute"?1:-1;var c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?this.offsetParent:this.scrollParent,e=/(html|body)/i.test(c[0].tagName);return{top:b.top+
+this.offset.relative.top*a+this.offset.parent.top*a-(d.browser.safari&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollTop():e?0:c.scrollTop())*a),left:b.left+this.offset.relative.left*a+this.offset.parent.left*a-(d.browser.safari&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():e?0:c.scrollLeft())*a)}},_generatePosition:function(a){var b=this.options,c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],
+this.offsetParent[0]))?this.offsetParent:this.scrollParent,e=/(html|body)/i.test(c[0].tagName);if(this.cssPosition=="relative"&&!(this.scrollParent[0]!=document&&this.scrollParent[0]!=this.offsetParent[0]))this.offset.relative=this._getRelativeOffset();var f=a.pageX,g=a.pageY;if(this.originalPosition){if(this.containment){if(a.pageX-this.offset.click.left<this.containment[0])f=this.containment[0]+this.offset.click.left;if(a.pageY-this.offset.click.top<this.containment[1])g=this.containment[1]+this.offset.click.top;
+if(a.pageX-this.offset.click.left>this.containment[2])f=this.containment[2]+this.offset.click.left;if(a.pageY-this.offset.click.top>this.containment[3])g=this.containment[3]+this.offset.click.top}if(b.grid){g=this.originalPageY+Math.round((g-this.originalPageY)/b.grid[1])*b.grid[1];g=this.containment?!(g-this.offset.click.top<this.containment[1]||g-this.offset.click.top>this.containment[3])?g:!(g-this.offset.click.top<this.containment[1])?g-b.grid[1]:g+b.grid[1]:g;f=this.originalPageX+Math.round((f-
+this.originalPageX)/b.grid[0])*b.grid[0];f=this.containment?!(f-this.offset.click.left<this.containment[0]||f-this.offset.click.left>this.containment[2])?f:!(f-this.offset.click.left<this.containment[0])?f-b.grid[0]:f+b.grid[0]:f}}return{top:g-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+(d.browser.safari&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollTop():e?0:c.scrollTop()),left:f-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+
+(d.browser.safari&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():e?0:c.scrollLeft())}},_rearrange:function(a,b,c,e){c?c[0].appendChild(this.placeholder[0]):b.item[0].parentNode.insertBefore(this.placeholder[0],this.direction=="down"?b.item[0]:b.item[0].nextSibling);this.counter=this.counter?++this.counter:1;var f=this,g=this.counter;window.setTimeout(function(){g==f.counter&&f.refreshPositions(!e)},0)},_clear:function(a,b){this.reverting=false;var c=[];!this._noFinalSort&&
+this.currentItem[0].parentNode&&this.placeholder.before(this.currentItem);this._noFinalSort=null;if(this.helper[0]==this.currentItem[0]){for(var e in this._storedCSS)if(this._storedCSS[e]=="auto"||this._storedCSS[e]=="static")this._storedCSS[e]="";this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper")}else this.currentItem.show();this.fromOutside&&!b&&c.push(function(f){this._trigger("receive",f,this._uiHash(this.fromOutside))});if((this.fromOutside||this.domPosition.prev!=this.currentItem.prev().not(".ui-sortable-helper")[0]||
+this.domPosition.parent!=this.currentItem.parent()[0])&&!b)c.push(function(f){this._trigger("update",f,this._uiHash())});if(!d.ui.contains(this.element[0],this.currentItem[0])){b||c.push(function(f){this._trigger("remove",f,this._uiHash())});for(e=this.containers.length-1;e>=0;e--)if(d.ui.contains(this.containers[e].element[0],this.currentItem[0])&&!b){c.push(function(f){return function(g){f._trigger("receive",g,this._uiHash(this))}}.call(this,this.containers[e]));c.push(function(f){return function(g){f._trigger("update",
+g,this._uiHash(this))}}.call(this,this.containers[e]))}}for(e=this.containers.length-1;e>=0;e--){b||c.push(function(f){return function(g){f._trigger("deactivate",g,this._uiHash(this))}}.call(this,this.containers[e]));if(this.containers[e].containerCache.over){c.push(function(f){return function(g){f._trigger("out",g,this._uiHash(this))}}.call(this,this.containers[e]));this.containers[e].containerCache.over=0}}this._storedCursor&&d("body").css("cursor",this._storedCursor);this._storedOpacity&&this.helper.css("opacity",
+this._storedOpacity);if(this._storedZIndex)this.helper.css("zIndex",this._storedZIndex=="auto"?"":this._storedZIndex);this.dragging=false;if(this.cancelHelperRemoval){if(!b){this._trigger("beforeStop",a,this._uiHash());for(e=0;e<c.length;e++)c[e].call(this,a);this._trigger("stop",a,this._uiHash())}return false}b||this._trigger("beforeStop",a,this._uiHash());this.placeholder[0].parentNode.removeChild(this.placeholder[0]);this.helper[0]!=this.currentItem[0]&&this.helper.remove();this.helper=null;if(!b){for(e=
+0;e<c.length;e++)c[e].call(this,a);this._trigger("stop",a,this._uiHash())}this.fromOutside=false;return true},_trigger:function(){d.Widget.prototype._trigger.apply(this,arguments)===false&&this.cancel()},_uiHash:function(a){var b=a||this;return{helper:b.helper,placeholder:b.placeholder||d([]),position:b.position,originalPosition:b.originalPosition,offset:b.positionAbs,item:b.currentItem,sender:a?a.element:null}}});d.extend(d.ui.sortable,{version:"1.8.2"})})(jQuery);
+;/*
+ * jQuery UI Accordion 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Accordion
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.widget.js
+ */
+(function(c){c.widget("ui.accordion",{options:{active:0,animated:"slide",autoHeight:true,clearStyle:false,collapsible:false,event:"click",fillSpace:false,header:"> li > :first-child,> :not(li):even",icons:{header:"ui-icon-triangle-1-e",headerSelected:"ui-icon-triangle-1-s"},navigation:false,navigationFilter:function(){return this.href.toLowerCase()==location.href.toLowerCase()}},_create:function(){var a=this.options,b=this;this.running=0;this.element.addClass("ui-accordion ui-widget ui-helper-reset");
+this.element.children("li").addClass("ui-accordion-li-fix");this.headers=this.element.find(a.header).addClass("ui-accordion-header ui-helper-reset ui-state-default ui-corner-all").bind("mouseenter.accordion",function(){c(this).addClass("ui-state-hover")}).bind("mouseleave.accordion",function(){c(this).removeClass("ui-state-hover")}).bind("focus.accordion",function(){c(this).addClass("ui-state-focus")}).bind("blur.accordion",function(){c(this).removeClass("ui-state-focus")});this.headers.next().addClass("ui-accordion-content ui-helper-reset ui-widget-content ui-corner-bottom");
+if(a.navigation){var d=this.element.find("a").filter(a.navigationFilter);if(d.length){var f=d.closest(".ui-accordion-header");this.active=f.length?f:d.closest(".ui-accordion-content").prev()}}this.active=this._findActive(this.active||a.active).toggleClass("ui-state-default").toggleClass("ui-state-active").toggleClass("ui-corner-all").toggleClass("ui-corner-top");this.active.next().addClass("ui-accordion-content-active");this._createIcons();this.resize();this.element.attr("role","tablist");this.headers.attr("role",
+"tab").bind("keydown",function(g){return b._keydown(g)}).next().attr("role","tabpanel");this.headers.not(this.active||"").attr("aria-expanded","false").attr("tabIndex","-1").next().hide();this.active.length?this.active.attr("aria-expanded","true").attr("tabIndex","0"):this.headers.eq(0).attr("tabIndex","0");c.browser.safari||this.headers.find("a").attr("tabIndex","-1");a.event&&this.headers.bind(a.event+".accordion",function(g){b._clickHandler.call(b,g,this);g.preventDefault()})},_createIcons:function(){var a=
+this.options;if(a.icons){c("<span/>").addClass("ui-icon "+a.icons.header).prependTo(this.headers);this.active.find(".ui-icon").toggleClass(a.icons.header).toggleClass(a.icons.headerSelected);this.element.addClass("ui-accordion-icons")}},_destroyIcons:function(){this.headers.children(".ui-icon").remove();this.element.removeClass("ui-accordion-icons")},destroy:function(){var a=this.options;this.element.removeClass("ui-accordion ui-widget ui-helper-reset").removeAttr("role").unbind(".accordion").removeData("accordion");
+this.headers.unbind(".accordion").removeClass("ui-accordion-header ui-helper-reset ui-state-default ui-corner-all ui-state-active ui-corner-top").removeAttr("role").removeAttr("aria-expanded").removeAttr("tabIndex");this.headers.find("a").removeAttr("tabIndex");this._destroyIcons();var b=this.headers.next().css("display","").removeAttr("role").removeClass("ui-helper-reset ui-widget-content ui-corner-bottom ui-accordion-content ui-accordion-content-active");if(a.autoHeight||a.fillHeight)b.css("height",
+"");return this},_setOption:function(a,b){c.Widget.prototype._setOption.apply(this,arguments);a=="active"&&this.activate(b);if(a=="icons"){this._destroyIcons();b&&this._createIcons()}},_keydown:function(a){var b=c.ui.keyCode;if(!(this.options.disabled||a.altKey||a.ctrlKey)){var d=this.headers.length,f=this.headers.index(a.target),g=false;switch(a.keyCode){case b.RIGHT:case b.DOWN:g=this.headers[(f+1)%d];break;case b.LEFT:case b.UP:g=this.headers[(f-1+d)%d];break;case b.SPACE:case b.ENTER:this._clickHandler({target:a.target},
+a.target);a.preventDefault()}if(g){c(a.target).attr("tabIndex","-1");c(g).attr("tabIndex","0");g.focus();return false}return true}},resize:function(){var a=this.options,b;if(a.fillSpace){if(c.browser.msie){var d=this.element.parent().css("overflow");this.element.parent().css("overflow","hidden")}b=this.element.parent().height();c.browser.msie&&this.element.parent().css("overflow",d);this.headers.each(function(){b-=c(this).outerHeight(true)});this.headers.next().each(function(){c(this).height(Math.max(0,
+b-c(this).innerHeight()+c(this).height()))}).css("overflow","auto")}else if(a.autoHeight){b=0;this.headers.next().each(function(){b=Math.max(b,c(this).height())}).height(b)}return this},activate:function(a){this.options.active=a;a=this._findActive(a)[0];this._clickHandler({target:a},a);return this},_findActive:function(a){return a?typeof a=="number"?this.headers.filter(":eq("+a+")"):this.headers.not(this.headers.not(a)):a===false?c([]):this.headers.filter(":eq(0)")},_clickHandler:function(a,b){var d=
+this.options;if(!d.disabled)if(a.target){a=c(a.currentTarget||b);b=a[0]==this.active[0];d.active=d.collapsible&&b?false:c(".ui-accordion-header",this.element).index(a);if(!(this.running||!d.collapsible&&b)){this.active.removeClass("ui-state-active ui-corner-top").addClass("ui-state-default ui-corner-all").find(".ui-icon").removeClass(d.icons.headerSelected).addClass(d.icons.header);if(!b){a.removeClass("ui-state-default ui-corner-all").addClass("ui-state-active ui-corner-top").find(".ui-icon").removeClass(d.icons.header).addClass(d.icons.headerSelected);
+a.next().addClass("ui-accordion-content-active")}e=a.next();f=this.active.next();g={options:d,newHeader:b&&d.collapsible?c([]):a,oldHeader:this.active,newContent:b&&d.collapsible?c([]):e,oldContent:f};d=this.headers.index(this.active[0])>this.headers.index(a[0]);this.active=b?c([]):a;this._toggle(e,f,g,b,d)}}else if(d.collapsible){this.active.removeClass("ui-state-active ui-corner-top").addClass("ui-state-default ui-corner-all").find(".ui-icon").removeClass(d.icons.headerSelected).addClass(d.icons.header);
+this.active.next().addClass("ui-accordion-content-active");var f=this.active.next(),g={options:d,newHeader:c([]),oldHeader:d.active,newContent:c([]),oldContent:f},e=this.active=c([]);this._toggle(e,f,g)}},_toggle:function(a,b,d,f,g){var e=this.options,k=this;this.toShow=a;this.toHide=b;this.data=d;var i=function(){if(k)return k._completed.apply(k,arguments)};this._trigger("changestart",null,this.data);this.running=b.size()===0?a.size():b.size();if(e.animated){d={};d=e.collapsible&&f?{toShow:c([]),
+toHide:b,complete:i,down:g,autoHeight:e.autoHeight||e.fillSpace}:{toShow:a,toHide:b,complete:i,down:g,autoHeight:e.autoHeight||e.fillSpace};if(!e.proxied)e.proxied=e.animated;if(!e.proxiedDuration)e.proxiedDuration=e.duration;e.animated=c.isFunction(e.proxied)?e.proxied(d):e.proxied;e.duration=c.isFunction(e.proxiedDuration)?e.proxiedDuration(d):e.proxiedDuration;f=c.ui.accordion.animations;var h=e.duration,j=e.animated;if(j&&!f[j]&&!c.easing[j])j="slide";f[j]||(f[j]=function(l){this.slide(l,{easing:j,
+duration:h||700})});f[j](d)}else{if(e.collapsible&&f)a.toggle();else{b.hide();a.show()}i(true)}b.prev().attr("aria-expanded","false").attr("tabIndex","-1").blur();a.prev().attr("aria-expanded","true").attr("tabIndex","0").focus()},_completed:function(a){var b=this.options;this.running=a?0:--this.running;if(!this.running){b.clearStyle&&this.toShow.add(this.toHide).css({height:"",overflow:""});this.toHide.removeClass("ui-accordion-content-active");this._trigger("change",null,this.data)}}});c.extend(c.ui.accordion,
+{version:"1.8.2",animations:{slide:function(a,b){a=c.extend({easing:"swing",duration:300},a,b);if(a.toHide.size())if(a.toShow.size()){var d=a.toShow.css("overflow"),f=0,g={},e={},k;b=a.toShow;k=b[0].style.width;b.width(parseInt(b.parent().width(),10)-parseInt(b.css("paddingLeft"),10)-parseInt(b.css("paddingRight"),10)-(parseInt(b.css("borderLeftWidth"),10)||0)-(parseInt(b.css("borderRightWidth"),10)||0));c.each(["height","paddingTop","paddingBottom"],function(i,h){e[h]="hide";i=(""+c.css(a.toShow[0],
+h)).match(/^([\d+-.]+)(.*)$/);g[h]={value:i[1],unit:i[2]||"px"}});a.toShow.css({height:0,overflow:"hidden"}).show();a.toHide.filter(":hidden").each(a.complete).end().filter(":visible").animate(e,{step:function(i,h){if(h.prop=="height")f=h.end-h.start===0?0:(h.now-h.start)/(h.end-h.start);a.toShow[0].style[h.prop]=f*g[h.prop].value+g[h.prop].unit},duration:a.duration,easing:a.easing,complete:function(){a.autoHeight||a.toShow.css("height","");a.toShow.css("width",k);a.toShow.css({overflow:d});a.complete()}})}else a.toHide.animate({height:"hide"},
+a);else a.toShow.animate({height:"show"},a)},bounceslide:function(a){this.slide(a,{easing:a.down?"easeOutBounce":"swing",duration:a.down?1E3:200})}}})})(jQuery);
+;/*
+ * jQuery UI Autocomplete 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Autocomplete
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.widget.js
+ *     jquery.ui.position.js
+ */
+(function(e){e.widget("ui.autocomplete",{options:{minLength:1,delay:300},_create:function(){var a=this,c=this.element[0].ownerDocument;this.element.addClass("ui-autocomplete-input").attr("autocomplete","off").attr({role:"textbox","aria-autocomplete":"list","aria-haspopup":"true"}).bind("keydown.autocomplete",function(d){var b=e.ui.keyCode;switch(d.keyCode){case b.PAGE_UP:a._move("previousPage",d);break;case b.PAGE_DOWN:a._move("nextPage",d);break;case b.UP:a._move("previous",d);d.preventDefault();
+break;case b.DOWN:a._move("next",d);d.preventDefault();break;case b.ENTER:case b.NUMPAD_ENTER:a.menu.active&&d.preventDefault();case b.TAB:if(!a.menu.active)return;a.menu.select(d);break;case b.ESCAPE:a.element.val(a.term);a.close(d);break;case b.LEFT:case b.RIGHT:case b.SHIFT:case b.CONTROL:case b.ALT:case b.COMMAND:case b.COMMAND_RIGHT:case b.INSERT:case b.CAPS_LOCK:case b.END:case b.HOME:break;default:clearTimeout(a.searching);a.searching=setTimeout(function(){a.search(null,d)},a.options.delay);
+break}}).bind("focus.autocomplete",function(){a.selectedItem=null;a.previous=a.element.val()}).bind("blur.autocomplete",function(d){clearTimeout(a.searching);a.closing=setTimeout(function(){a.close(d);a._change(d)},150)});this._initSource();this.response=function(){return a._response.apply(a,arguments)};this.menu=e("<ul></ul>").addClass("ui-autocomplete").appendTo("body",c).mousedown(function(){setTimeout(function(){clearTimeout(a.closing)},13)}).menu({focus:function(d,b){b=b.item.data("item.autocomplete");
+false!==a._trigger("focus",null,{item:b})&&/^key/.test(d.originalEvent.type)&&a.element.val(b.value)},selected:function(d,b){b=b.item.data("item.autocomplete");false!==a._trigger("select",d,{item:b})&&a.element.val(b.value);a.close(d);d=a.previous;if(a.element[0]!==c.activeElement){a.element.focus();a.previous=d}a.selectedItem=b},blur:function(){a.menu.element.is(":visible")&&a.element.val(a.term)}}).zIndex(this.element.zIndex()+1).css({top:0,left:0}).hide().data("menu");e.fn.bgiframe&&this.menu.element.bgiframe()},
+destroy:function(){this.element.removeClass("ui-autocomplete-input").removeAttr("autocomplete").removeAttr("role").removeAttr("aria-autocomplete").removeAttr("aria-haspopup");this.menu.element.remove();e.Widget.prototype.destroy.call(this)},_setOption:function(a){e.Widget.prototype._setOption.apply(this,arguments);a==="source"&&this._initSource()},_initSource:function(){var a,c;if(e.isArray(this.options.source)){a=this.options.source;this.source=function(d,b){b(e.ui.autocomplete.filter(a,d.term))}}else if(typeof this.options.source===
+"string"){c=this.options.source;this.source=function(d,b){e.getJSON(c,d,b)}}else this.source=this.options.source},search:function(a,c){a=a!=null?a:this.element.val();if(a.length<this.options.minLength)return this.close(c);clearTimeout(this.closing);if(this._trigger("search")!==false)return this._search(a)},_search:function(a){this.term=this.element.addClass("ui-autocomplete-loading").val();this.source({term:a},this.response)},_response:function(a){if(a.length){a=this._normalize(a);this._suggest(a);
+this._trigger("open")}else this.close();this.element.removeClass("ui-autocomplete-loading")},close:function(a){clearTimeout(this.closing);if(this.menu.element.is(":visible")){this._trigger("close",a);this.menu.element.hide();this.menu.deactivate()}},_change:function(a){this.previous!==this.element.val()&&this._trigger("change",a,{item:this.selectedItem})},_normalize:function(a){if(a.length&&a[0].label&&a[0].value)return a;return e.map(a,function(c){if(typeof c==="string")return{label:c,value:c};return e.extend({label:c.label||
+c.value,value:c.value||c.label},c)})},_suggest:function(a){var c=this.menu.element.empty().zIndex(this.element.zIndex()+1),d;this._renderMenu(c,a);this.menu.deactivate();this.menu.refresh();this.menu.element.show().position({my:"left top",at:"left bottom",of:this.element,collision:"none"});a=c.width("").width();d=this.element.width();c.width(Math.max(a,d))},_renderMenu:function(a,c){var d=this;e.each(c,function(b,f){d._renderItem(a,f)})},_renderItem:function(a,c){return e("<li></li>").data("item.autocomplete",
+c).append("<a>"+c.label+"</a>").appendTo(a)},_move:function(a,c){if(this.menu.element.is(":visible"))if(this.menu.first()&&/^previous/.test(a)||this.menu.last()&&/^next/.test(a)){this.element.val(this.term);this.menu.deactivate()}else this.menu[a](c);else this.search(null,c)},widget:function(){return this.menu.element}});e.extend(e.ui.autocomplete,{escapeRegex:function(a){return a.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi,"\\$1")},filter:function(a,c){var d=new RegExp(e.ui.autocomplete.escapeRegex(c),
+"i");return e.grep(a,function(b){return d.test(b.label||b.value||b)})}})})(jQuery);
+(function(e){e.widget("ui.menu",{_create:function(){var a=this;this.element.addClass("ui-menu ui-widget ui-widget-content ui-corner-all").attr({role:"listbox","aria-activedescendant":"ui-active-menuitem"}).click(function(c){if(e(c.target).closest(".ui-menu-item a").length){c.preventDefault();a.select(c)}});this.refresh()},refresh:function(){var a=this;this.element.children("li:not(.ui-menu-item):has(a)").addClass("ui-menu-item").attr("role","menuitem").children("a").addClass("ui-corner-all").attr("tabindex",
+-1).mouseenter(function(c){a.activate(c,e(this).parent())}).mouseleave(function(){a.deactivate()})},activate:function(a,c){this.deactivate();if(this.hasScroll()){var d=c.offset().top-this.element.offset().top,b=this.element.attr("scrollTop"),f=this.element.height();if(d<0)this.element.attr("scrollTop",b+d);else d>f&&this.element.attr("scrollTop",b+d-f+c.height())}this.active=c.eq(0).children("a").addClass("ui-state-hover").attr("id","ui-active-menuitem").end();this._trigger("focus",a,{item:c})},deactivate:function(){if(this.active){this.active.children("a").removeClass("ui-state-hover").removeAttr("id");
+this._trigger("blur");this.active=null}},next:function(a){this.move("next",".ui-menu-item:first",a)},previous:function(a){this.move("prev",".ui-menu-item:last",a)},first:function(){return this.active&&!this.active.prev().length},last:function(){return this.active&&!this.active.next().length},move:function(a,c,d){if(this.active){a=this.active[a+"All"](".ui-menu-item").eq(0);a.length?this.activate(d,a):this.activate(d,this.element.children(c))}else this.activate(d,this.element.children(c))},nextPage:function(a){if(this.hasScroll())if(!this.active||
+this.last())this.activate(a,this.element.children(":first"));else{var c=this.active.offset().top,d=this.element.height(),b=this.element.children("li").filter(function(){var f=e(this).offset().top-c-d+e(this).height();return f<10&&f>-10});b.length||(b=this.element.children(":last"));this.activate(a,b)}else this.activate(a,this.element.children(!this.active||this.last()?":first":":last"))},previousPage:function(a){if(this.hasScroll())if(!this.active||this.first())this.activate(a,this.element.children(":last"));
+else{var c=this.active.offset().top,d=this.element.height();result=this.element.children("li").filter(function(){var b=e(this).offset().top-c+d-e(this).height();return b<10&&b>-10});result.length||(result=this.element.children(":first"));this.activate(a,result)}else this.activate(a,this.element.children(!this.active||this.first()?":last":":first"))},hasScroll:function(){return this.element.height()<this.element.attr("scrollHeight")},select:function(a){this._trigger("selected",a,{item:this.active})}})})(jQuery);
+;/*
+ * jQuery UI Button 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Button
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.widget.js
+ */
+(function(a){var g,i=function(b){a(":ui-button",b.target.form).each(function(){var c=a(this).data("button");setTimeout(function(){c.refresh()},1)})},h=function(b){var c=b.name,d=b.form,e=a([]);if(c)e=d?a(d).find("[name='"+c+"']"):a("[name='"+c+"']",b.ownerDocument).filter(function(){return!this.form});return e};a.widget("ui.button",{options:{text:true,label:null,icons:{primary:null,secondary:null}},_create:function(){this.element.closest("form").unbind("reset.button").bind("reset.button",i);this._determineButtonType();
+this.hasTitle=!!this.buttonElement.attr("title");var b=this,c=this.options,d=this.type==="checkbox"||this.type==="radio",e="ui-state-hover"+(!d?" ui-state-active":"");if(c.label===null)c.label=this.buttonElement.html();if(this.element.is(":disabled"))c.disabled=true;this.buttonElement.addClass("ui-button ui-widget ui-state-default ui-corner-all").attr("role","button").bind("mouseenter.button",function(){if(!c.disabled){a(this).addClass("ui-state-hover");this===g&&a(this).addClass("ui-state-active")}}).bind("mouseleave.button",
+function(){c.disabled||a(this).removeClass(e)}).bind("focus.button",function(){a(this).addClass("ui-state-focus")}).bind("blur.button",function(){a(this).removeClass("ui-state-focus")});d&&this.element.bind("change.button",function(){b.refresh()});if(this.type==="checkbox")this.buttonElement.bind("click.button",function(){if(c.disabled)return false;a(this).toggleClass("ui-state-active");b.buttonElement.attr("aria-pressed",b.element[0].checked)});else if(this.type==="radio")this.buttonElement.bind("click.button",
+function(){if(c.disabled)return false;a(this).addClass("ui-state-active");b.buttonElement.attr("aria-pressed",true);var f=b.element[0];h(f).not(f).map(function(){return a(this).button("widget")[0]}).removeClass("ui-state-active").attr("aria-pressed",false)});else{this.buttonElement.bind("mousedown.button",function(){if(c.disabled)return false;a(this).addClass("ui-state-active");g=this;a(document).one("mouseup",function(){g=null})}).bind("mouseup.button",function(){if(c.disabled)return false;a(this).removeClass("ui-state-active")}).bind("keydown.button",
+function(f){if(c.disabled)return false;if(f.keyCode==a.ui.keyCode.SPACE||f.keyCode==a.ui.keyCode.ENTER)a(this).addClass("ui-state-active")}).bind("keyup.button",function(){a(this).removeClass("ui-state-active")});this.buttonElement.is("a")&&this.buttonElement.keyup(function(f){f.keyCode===a.ui.keyCode.SPACE&&a(this).click()})}this._setOption("disabled",c.disabled)},_determineButtonType:function(){this.type=this.element.is(":checkbox")?"checkbox":this.element.is(":radio")?"radio":this.element.is("input")?
+"input":"button";if(this.type==="checkbox"||this.type==="radio"){this.buttonElement=this.element.parents().last().find("[for="+this.element.attr("id")+"]");this.element.addClass("ui-helper-hidden-accessible");var b=this.element.is(":checked");b&&this.buttonElement.addClass("ui-state-active");this.buttonElement.attr("aria-pressed",b)}else this.buttonElement=this.element},widget:function(){return this.buttonElement},destroy:function(){this.element.removeClass("ui-helper-hidden-accessible");this.buttonElement.removeClass("ui-button ui-widget ui-state-default ui-corner-all ui-state-hover ui-state-active  ui-button-icons-only ui-button-icon-only ui-button-text-icons ui-button-text-icon ui-button-text-only").removeAttr("role").removeAttr("aria-pressed").html(this.buttonElement.find(".ui-button-text").html());
+this.hasTitle||this.buttonElement.removeAttr("title");a.Widget.prototype.destroy.call(this)},_setOption:function(b,c){a.Widget.prototype._setOption.apply(this,arguments);if(b==="disabled")c?this.element.attr("disabled",true):this.element.removeAttr("disabled");this._resetButton()},refresh:function(){var b=this.element.is(":disabled");b!==this.options.disabled&&this._setOption("disabled",b);if(this.type==="radio")h(this.element[0]).each(function(){a(this).is(":checked")?a(this).button("widget").addClass("ui-state-active").attr("aria-pressed",
+true):a(this).button("widget").removeClass("ui-state-active").attr("aria-pressed",false)});else if(this.type==="checkbox")this.element.is(":checked")?this.buttonElement.addClass("ui-state-active").attr("aria-pressed",true):this.buttonElement.removeClass("ui-state-active").attr("aria-pressed",false)},_resetButton:function(){if(this.type==="input")this.options.label&&this.element.val(this.options.label);else{var b=this.buttonElement.removeClass("ui-button-icons-only ui-button-icon-only ui-button-text-icons ui-button-text-icon ui-button-text-only"),
+c=a("<span></span>").addClass("ui-button-text").html(this.options.label).appendTo(b.empty()).text(),d=this.options.icons,e=d.primary&&d.secondary;if(d.primary||d.secondary){b.addClass("ui-button-text-icon"+(e?"s":""));d.primary&&b.prepend("<span class='ui-button-icon-primary ui-icon "+d.primary+"'></span>");d.secondary&&b.append("<span class='ui-button-icon-secondary ui-icon "+d.secondary+"'></span>");if(!this.options.text){b.addClass(e?"ui-button-icons-only":"ui-button-icon-only").removeClass("ui-button-text-icons ui-button-text-icon");
+this.hasTitle||b.attr("title",c)}}else b.addClass("ui-button-text-only")}}});a.widget("ui.buttonset",{_create:function(){this.element.addClass("ui-buttonset");this._init()},_init:function(){this.refresh()},_setOption:function(b,c){b==="disabled"&&this.buttons.button("option",b,c);a.Widget.prototype._setOption.apply(this,arguments)},refresh:function(){this.buttons=this.element.find(":button, :submit, :reset, :checkbox, :radio, a, :data(button)").filter(":ui-button").button("refresh").end().not(":ui-button").button().end().map(function(){return a(this).button("widget")[0]}).removeClass("ui-corner-all ui-corner-left ui-corner-right").filter(":first").addClass("ui-corner-left").end().filter(":last").addClass("ui-corner-right").end().end()},
+destroy:function(){this.element.removeClass("ui-buttonset");this.buttons.map(function(){return a(this).button("widget")[0]}).removeClass("ui-corner-left ui-corner-right").end().button("destroy");a.Widget.prototype.destroy.call(this)}})})(jQuery);
+;/*
+ * jQuery UI Dialog 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Dialog
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.widget.js
+ *  jquery.ui.button.js
+ *     jquery.ui.draggable.js
+ *     jquery.ui.mouse.js
+ *     jquery.ui.position.js
+ *     jquery.ui.resizable.js
+ */
+(function(c){c.widget("ui.dialog",{options:{autoOpen:true,buttons:{},closeOnEscape:true,closeText:"close",dialogClass:"",draggable:true,hide:null,height:"auto",maxHeight:false,maxWidth:false,minHeight:150,minWidth:150,modal:false,position:"center",resizable:true,show:null,stack:true,title:"",width:300,zIndex:1E3},_create:function(){this.originalTitle=this.element.attr("title");var a=this,b=a.options,d=b.title||a.originalTitle||"&#160;",e=c.ui.dialog.getTitleId(a.element),g=(a.uiDialog=c("<div></div>")).appendTo(document.body).hide().addClass("ui-dialog ui-widget ui-widget-content ui-corner-all "+
+b.dialogClass).css({zIndex:b.zIndex}).attr("tabIndex",-1).css("outline",0).keydown(function(i){if(b.closeOnEscape&&i.keyCode&&i.keyCode===c.ui.keyCode.ESCAPE){a.close(i);i.preventDefault()}}).attr({role:"dialog","aria-labelledby":e}).mousedown(function(i){a.moveToTop(false,i)});a.element.show().removeAttr("title").addClass("ui-dialog-content ui-widget-content").appendTo(g);var f=(a.uiDialogTitlebar=c("<div></div>")).addClass("ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix").prependTo(g),
+h=c('<a href="#"></a>').addClass("ui-dialog-titlebar-close ui-corner-all").attr("role","button").hover(function(){h.addClass("ui-state-hover")},function(){h.removeClass("ui-state-hover")}).focus(function(){h.addClass("ui-state-focus")}).blur(function(){h.removeClass("ui-state-focus")}).click(function(i){a.close(i);return false}).appendTo(f);(a.uiDialogTitlebarCloseText=c("<span></span>")).addClass("ui-icon ui-icon-closethick").text(b.closeText).appendTo(h);c("<span></span>").addClass("ui-dialog-title").attr("id",
+e).html(d).prependTo(f);if(c.isFunction(b.beforeclose)&&!c.isFunction(b.beforeClose))b.beforeClose=b.beforeclose;f.find("*").add(f).disableSelection();b.draggable&&c.fn.draggable&&a._makeDraggable();b.resizable&&c.fn.resizable&&a._makeResizable();a._createButtons(b.buttons);a._isOpen=false;c.fn.bgiframe&&g.bgiframe()},_init:function(){this.options.autoOpen&&this.open()},destroy:function(){var a=this;a.overlay&&a.overlay.destroy();a.uiDialog.hide();a.element.unbind(".dialog").removeData("dialog").removeClass("ui-dialog-content ui-widget-content").hide().appendTo("body");
+a.uiDialog.remove();a.originalTitle&&a.element.attr("title",a.originalTitle);return a},widget:function(){return this.uiDialog},close:function(a){var b=this,d;if(false!==b._trigger("beforeClose",a)){b.overlay&&b.overlay.destroy();b.uiDialog.unbind("keypress.ui-dialog");b._isOpen=false;if(b.options.hide)b.uiDialog.hide(b.options.hide,function(){b._trigger("close",a)});else{b.uiDialog.hide();b._trigger("close",a)}c.ui.dialog.overlay.resize();if(b.options.modal){d=0;c(".ui-dialog").each(function(){if(this!==
+b.uiDialog[0])d=Math.max(d,c(this).css("z-index"))});c.ui.dialog.maxZ=d}return b}},isOpen:function(){return this._isOpen},moveToTop:function(a,b){var d=this,e=d.options;if(e.modal&&!a||!e.stack&&!e.modal)return d._trigger("focus",b);if(e.zIndex>c.ui.dialog.maxZ)c.ui.dialog.maxZ=e.zIndex;if(d.overlay){c.ui.dialog.maxZ+=1;d.overlay.$el.css("z-index",c.ui.dialog.overlay.maxZ=c.ui.dialog.maxZ)}a={scrollTop:d.element.attr("scrollTop"),scrollLeft:d.element.attr("scrollLeft")};c.ui.dialog.maxZ+=1;d.uiDialog.css("z-index",
+c.ui.dialog.maxZ);d.element.attr(a);d._trigger("focus",b);return d},open:function(){if(!this._isOpen){var a=this,b=a.options,d=a.uiDialog;a.overlay=b.modal?new c.ui.dialog.overlay(a):null;d.next().length&&d.appendTo("body");a._size();a._position(b.position);d.show(b.show);a.moveToTop(true);b.modal&&d.bind("keypress.ui-dialog",function(e){if(e.keyCode===c.ui.keyCode.TAB){var g=c(":tabbable",this),f=g.filter(":first");g=g.filter(":last");if(e.target===g[0]&&!e.shiftKey){f.focus(1);return false}else if(e.target===
+f[0]&&e.shiftKey){g.focus(1);return false}}});c([]).add(d.find(".ui-dialog-content :tabbable:first")).add(d.find(".ui-dialog-buttonpane :tabbable:first")).add(d).filter(":first").focus();a._trigger("open");a._isOpen=true;return a}},_createButtons:function(a){var b=this,d=false,e=c("<div></div>").addClass("ui-dialog-buttonpane ui-widget-content ui-helper-clearfix");b.uiDialog.find(".ui-dialog-buttonpane").remove();typeof a==="object"&&a!==null&&c.each(a,function(){return!(d=true)});if(d){c.each(a,
+function(g,f){g=c('<button type="button"></button>').text(g).click(function(){f.apply(b.element[0],arguments)}).appendTo(e);c.fn.button&&g.button()});e.appendTo(b.uiDialog)}},_makeDraggable:function(){function a(f){return{position:f.position,offset:f.offset}}var b=this,d=b.options,e=c(document),g;b.uiDialog.draggable({cancel:".ui-dialog-content, .ui-dialog-titlebar-close",handle:".ui-dialog-titlebar",containment:"document",start:function(f,h){g=d.height==="auto"?"auto":c(this).height();c(this).height(c(this).height()).addClass("ui-dialog-dragging");
+b._trigger("dragStart",f,a(h))},drag:function(f,h){b._trigger("drag",f,a(h))},stop:function(f,h){d.position=[h.position.left-e.scrollLeft(),h.position.top-e.scrollTop()];c(this).removeClass("ui-dialog-dragging").height(g);b._trigger("dragStop",f,a(h));c.ui.dialog.overlay.resize()}})},_makeResizable:function(a){function b(f){return{originalPosition:f.originalPosition,originalSize:f.originalSize,position:f.position,size:f.size}}a=a===undefined?this.options.resizable:a;var d=this,e=d.options,g=d.uiDialog.css("position");
+a=typeof a==="string"?a:"n,e,s,w,se,sw,ne,nw";d.uiDialog.resizable({cancel:".ui-dialog-content",containment:"document",alsoResize:d.element,maxWidth:e.maxWidth,maxHeight:e.maxHeight,minWidth:e.minWidth,minHeight:d._minHeight(),handles:a,start:function(f,h){c(this).addClass("ui-dialog-resizing");d._trigger("resizeStart",f,b(h))},resize:function(f,h){d._trigger("resize",f,b(h))},stop:function(f,h){c(this).removeClass("ui-dialog-resizing");e.height=c(this).height();e.width=c(this).width();d._trigger("resizeStop",
+f,b(h));c.ui.dialog.overlay.resize()}}).css("position",g).find(".ui-resizable-se").addClass("ui-icon ui-icon-grip-diagonal-se")},_minHeight:function(){var a=this.options;return a.height==="auto"?a.minHeight:Math.min(a.minHeight,a.height)},_position:function(a){var b=[],d=[0,0];a=a||c.ui.dialog.prototype.options.position;if(typeof a==="string"||typeof a==="object"&&"0"in a){b=a.split?a.split(" "):[a[0],a[1]];if(b.length===1)b[1]=b[0];c.each(["left","top"],function(e,g){if(+b[e]===b[e]){d[e]=b[e];b[e]=
+g}})}else if(typeof a==="object"){if("left"in a){b[0]="left";d[0]=a.left}else if("right"in a){b[0]="right";d[0]=-a.right}if("top"in a){b[1]="top";d[1]=a.top}else if("bottom"in a){b[1]="bottom";d[1]=-a.bottom}}(a=this.uiDialog.is(":visible"))||this.uiDialog.show();this.uiDialog.css({top:0,left:0}).position({my:b.join(" "),at:b.join(" "),offset:d.join(" "),of:window,collision:"fit",using:function(e){var g=c(this).css(e).offset().top;g<0&&c(this).css("top",e.top-g)}});a||this.uiDialog.hide()},_setOption:function(a,
+b){var d=this,e=d.uiDialog,g=e.is(":data(resizable)"),f=false;switch(a){case "beforeclose":a="beforeClose";break;case "buttons":d._createButtons(b);break;case "closeText":d.uiDialogTitlebarCloseText.text(""+b);break;case "dialogClass":e.removeClass(d.options.dialogClass).addClass("ui-dialog ui-widget ui-widget-content ui-corner-all "+b);break;case "disabled":b?e.addClass("ui-dialog-disabled"):e.removeClass("ui-dialog-disabled");break;case "draggable":b?d._makeDraggable():e.draggable("destroy");break;
+case "height":f=true;break;case "maxHeight":g&&e.resizable("option","maxHeight",b);f=true;break;case "maxWidth":g&&e.resizable("option","maxWidth",b);f=true;break;case "minHeight":g&&e.resizable("option","minHeight",b);f=true;break;case "minWidth":g&&e.resizable("option","minWidth",b);f=true;break;case "position":d._position(b);break;case "resizable":g&&!b&&e.resizable("destroy");g&&typeof b==="string"&&e.resizable("option","handles",b);!g&&b!==false&&d._makeResizable(b);break;case "title":c(".ui-dialog-title",
+d.uiDialogTitlebar).html(""+(b||"&#160;"));break;case "width":f=true;break}c.Widget.prototype._setOption.apply(d,arguments);f&&d._size()},_size:function(){var a=this.options,b;this.element.css({width:"auto",minHeight:0,height:0});b=this.uiDialog.css({height:"auto",width:a.width}).height();this.element.css(a.height==="auto"?{minHeight:Math.max(a.minHeight-b,0),height:"auto"}:{minHeight:0,height:Math.max(a.height-b,0)}).show();this.uiDialog.is(":data(resizable)")&&this.uiDialog.resizable("option","minHeight",
+this._minHeight())}});c.extend(c.ui.dialog,{version:"1.8.2",uuid:0,maxZ:0,getTitleId:function(a){a=a.attr("id");if(!a){this.uuid+=1;a=this.uuid}return"ui-dialog-title-"+a},overlay:function(a){this.$el=c.ui.dialog.overlay.create(a)}});c.extend(c.ui.dialog.overlay,{instances:[],oldInstances:[],maxZ:0,events:c.map("focus,mousedown,mouseup,keydown,keypress,click".split(","),function(a){return a+".dialog-overlay"}).join(" "),create:function(a){if(this.instances.length===0){setTimeout(function(){c.ui.dialog.overlay.instances.length&&
+c(document).bind(c.ui.dialog.overlay.events,function(d){return c(d.target).zIndex()>=c.ui.dialog.overlay.maxZ})},1);c(document).bind("keydown.dialog-overlay",function(d){if(a.options.closeOnEscape&&d.keyCode&&d.keyCode===c.ui.keyCode.ESCAPE){a.close(d);d.preventDefault()}});c(window).bind("resize.dialog-overlay",c.ui.dialog.overlay.resize)}var b=(this.oldInstances.pop()||c("<div></div>").addClass("ui-widget-overlay")).appendTo(document.body).css({width:this.width(),height:this.height()});c.fn.bgiframe&&
+b.bgiframe();this.instances.push(b);return b},destroy:function(a){this.oldInstances.push(this.instances.splice(c.inArray(a,this.instances),1)[0]);this.instances.length===0&&c([document,window]).unbind(".dialog-overlay");a.remove();var b=0;c.each(this.instances,function(){b=Math.max(b,this.css("z-index"))});this.maxZ=b},height:function(){var a,b;if(c.browser.msie&&c.browser.version<7){a=Math.max(document.documentElement.scrollHeight,document.body.scrollHeight);b=Math.max(document.documentElement.offsetHeight,
+document.body.offsetHeight);return a<b?c(window).height()+"px":a+"px"}else return c(document).height()+"px"},width:function(){var a,b;if(c.browser.msie&&c.browser.version<7){a=Math.max(document.documentElement.scrollWidth,document.body.scrollWidth);b=Math.max(document.documentElement.offsetWidth,document.body.offsetWidth);return a<b?c(window).width()+"px":a+"px"}else return c(document).width()+"px"},resize:function(){var a=c([]);c.each(c.ui.dialog.overlay.instances,function(){a=a.add(this)});a.css({width:0,
+height:0}).css({width:c.ui.dialog.overlay.width(),height:c.ui.dialog.overlay.height()})}});c.extend(c.ui.dialog.overlay.prototype,{destroy:function(){c.ui.dialog.overlay.destroy(this.$el)}})})(jQuery);
+;/*
+ * jQuery UI Slider 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Slider
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.mouse.js
+ *     jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.slider",d.ui.mouse,{widgetEventPrefix:"slide",options:{animate:false,distance:0,max:100,min:0,orientation:"horizontal",range:false,step:1,value:0,values:null},_create:function(){var a=this,b=this.options;this._mouseSliding=this._keySliding=false;this._animateOff=true;this._handleIndex=null;this._detectOrientation();this._mouseInit();this.element.addClass("ui-slider ui-slider-"+this.orientation+" ui-widget ui-widget-content ui-corner-all");b.disabled&&this.element.addClass("ui-slider-disabled ui-disabled");
+this.range=d([]);if(b.range){if(b.range===true){this.range=d("<div></div>");if(!b.values)b.values=[this._valueMin(),this._valueMin()];if(b.values.length&&b.values.length!==2)b.values=[b.values[0],b.values[0]]}else this.range=d("<div></div>");this.range.appendTo(this.element).addClass("ui-slider-range");if(b.range==="min"||b.range==="max")this.range.addClass("ui-slider-range-"+b.range);this.range.addClass("ui-widget-header")}d(".ui-slider-handle",this.element).length===0&&d("<a href='#'></a>").appendTo(this.element).addClass("ui-slider-handle");
+if(b.values&&b.values.length)for(;d(".ui-slider-handle",this.element).length<b.values.length;)d("<a href='#'></a>").appendTo(this.element).addClass("ui-slider-handle");this.handles=d(".ui-slider-handle",this.element).addClass("ui-state-default ui-corner-all");this.handle=this.handles.eq(0);this.handles.add(this.range).filter("a").click(function(c){c.preventDefault()}).hover(function(){b.disabled||d(this).addClass("ui-state-hover")},function(){d(this).removeClass("ui-state-hover")}).focus(function(){if(b.disabled)d(this).blur();
+else{d(".ui-slider .ui-state-focus").removeClass("ui-state-focus");d(this).addClass("ui-state-focus")}}).blur(function(){d(this).removeClass("ui-state-focus")});this.handles.each(function(c){d(this).data("index.ui-slider-handle",c)});this.handles.keydown(function(c){var e=true,f=d(this).data("index.ui-slider-handle"),g,h,i;if(!a.options.disabled){switch(c.keyCode){case d.ui.keyCode.HOME:case d.ui.keyCode.END:case d.ui.keyCode.PAGE_UP:case d.ui.keyCode.PAGE_DOWN:case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:e=
+false;if(!a._keySliding){a._keySliding=true;d(this).addClass("ui-state-active");g=a._start(c,f);if(g===false)return}break}i=a.options.step;g=a.options.values&&a.options.values.length?(h=a.values(f)):(h=a.value());switch(c.keyCode){case d.ui.keyCode.HOME:h=a._valueMin();break;case d.ui.keyCode.END:h=a._valueMax();break;case d.ui.keyCode.PAGE_UP:h=a._trimAlignValue(g+(a._valueMax()-a._valueMin())/5);break;case d.ui.keyCode.PAGE_DOWN:h=a._trimAlignValue(g-(a._valueMax()-a._valueMin())/5);break;case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:if(g===
+a._valueMax())return;h=a._trimAlignValue(g+i);break;case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:if(g===a._valueMin())return;h=a._trimAlignValue(g-i);break}a._slide(c,f,h);return e}}).keyup(function(c){var e=d(this).data("index.ui-slider-handle");if(a._keySliding){a._keySliding=false;a._stop(c,e);a._change(c,e);d(this).removeClass("ui-state-active")}});this._refreshValue();this._animateOff=false},destroy:function(){this.handles.remove();this.range.remove();this.element.removeClass("ui-slider ui-slider-horizontal ui-slider-vertical ui-slider-disabled ui-widget ui-widget-content ui-corner-all").removeData("slider").unbind(".slider");
+this._mouseDestroy();return this},_mouseCapture:function(a){var b=this.options,c,e,f,g,h,i;if(b.disabled)return false;this.elementSize={width:this.element.outerWidth(),height:this.element.outerHeight()};this.elementOffset=this.element.offset();c={x:a.pageX,y:a.pageY};e=this._normValueFromMouse(c);f=this._valueMax()-this._valueMin()+1;h=this;this.handles.each(function(j){var k=Math.abs(e-h.values(j));if(f>k){f=k;g=d(this);i=j}});if(b.range===true&&this.values(1)===b.min){i+=1;g=d(this.handles[i])}if(this._start(a,
+i)===false)return false;this._mouseSliding=true;h._handleIndex=i;g.addClass("ui-state-active").focus();b=g.offset();this._clickOffset=!d(a.target).parents().andSelf().is(".ui-slider-handle")?{left:0,top:0}:{left:a.pageX-b.left-g.width()/2,top:a.pageY-b.top-g.height()/2-(parseInt(g.css("borderTopWidth"),10)||0)-(parseInt(g.css("borderBottomWidth"),10)||0)+(parseInt(g.css("marginTop"),10)||0)};e=this._normValueFromMouse(c);this._slide(a,i,e);return this._animateOff=true},_mouseStart:function(){return true},
+_mouseDrag:function(a){var b=this._normValueFromMouse({x:a.pageX,y:a.pageY});this._slide(a,this._handleIndex,b);return false},_mouseStop:function(a){this.handles.removeClass("ui-state-active");this._mouseSliding=false;this._stop(a,this._handleIndex);this._change(a,this._handleIndex);this._clickOffset=this._handleIndex=null;return this._animateOff=false},_detectOrientation:function(){this.orientation=this.options.orientation==="vertical"?"vertical":"horizontal"},_normValueFromMouse:function(a){var b;
+if(this.orientation==="horizontal"){b=this.elementSize.width;a=a.x-this.elementOffset.left-(this._clickOffset?this._clickOffset.left:0)}else{b=this.elementSize.height;a=a.y-this.elementOffset.top-(this._clickOffset?this._clickOffset.top:0)}b=a/b;if(b>1)b=1;if(b<0)b=0;if(this.orientation==="vertical")b=1-b;a=this._valueMax()-this._valueMin();return this._trimAlignValue(this._valueMin()+b*a)},_start:function(a,b){var c={handle:this.handles[b],value:this.value()};if(this.options.values&&this.options.values.length){c.value=
+this.values(b);c.values=this.values()}return this._trigger("start",a,c)},_slide:function(a,b,c){var e;if(this.options.values&&this.options.values.length){e=this.values(b?0:1);if(this.options.values.length===2&&this.options.range===true&&(b===0&&c>e||b===1&&c<e))c=e;if(c!==this.values(b)){e=this.values();e[b]=c;a=this._trigger("slide",a,{handle:this.handles[b],value:c,values:e});this.values(b?0:1);a!==false&&this.values(b,c,true)}}else if(c!==this.value()){a=this._trigger("slide",a,{handle:this.handles[b],
+value:c});a!==false&&this.value(c)}},_stop:function(a,b){var c={handle:this.handles[b],value:this.value()};if(this.options.values&&this.options.values.length){c.value=this.values(b);c.values=this.values()}this._trigger("stop",a,c)},_change:function(a,b){if(!this._keySliding&&!this._mouseSliding){var c={handle:this.handles[b],value:this.value()};if(this.options.values&&this.options.values.length){c.value=this.values(b);c.values=this.values()}this._trigger("change",a,c)}},value:function(a){if(arguments.length){this.options.value=
+this._trimAlignValue(a);this._refreshValue();this._change(null,0)}return this._value()},values:function(a,b){var c,e,f;if(arguments.length>1){this.options.values[a]=this._trimAlignValue(b);this._refreshValue();this._change(null,a)}if(arguments.length)if(d.isArray(arguments[0])){c=this.options.values;e=arguments[0];for(f=0;f<c.length;f+=1){c[f]=this._trimAlignValue(e[f]);this._change(null,f)}this._refreshValue()}else return this.options.values&&this.options.values.length?this._values(a):this.value();
+else return this._values()},_setOption:function(a,b){var c,e=0;if(d.isArray(this.options.values))e=this.options.values.length;d.Widget.prototype._setOption.apply(this,arguments);switch(a){case "disabled":if(b){this.handles.filter(".ui-state-focus").blur();this.handles.removeClass("ui-state-hover");this.handles.attr("disabled","disabled");this.element.addClass("ui-disabled")}else{this.handles.removeAttr("disabled");this.element.removeClass("ui-disabled")}break;case "orientation":this._detectOrientation();
+this.element.removeClass("ui-slider-horizontal ui-slider-vertical").addClass("ui-slider-"+this.orientation);this._refreshValue();break;case "value":this._animateOff=true;this._refreshValue();this._change(null,0);this._animateOff=false;break;case "values":this._animateOff=true;this._refreshValue();for(c=0;c<e;c+=1)this._change(null,c);this._animateOff=false;break}},_value:function(){var a=this.options.value;return a=this._trimAlignValue(a)},_values:function(a){var b,c;if(arguments.length){b=this.options.values[a];
+return b=this._trimAlignValue(b)}else{b=this.options.values.slice();for(c=0;c<b.length;c+=1)b[c]=this._trimAlignValue(b[c]);return b}},_trimAlignValue:function(a){if(a<this._valueMin())return this._valueMin();if(a>this._valueMax())return this._valueMax();var b=this.options.step>0?this.options.step:1,c=a%b;a=a-c;if(Math.abs(c)*2>=b)a+=c>0?b:-b;return parseFloat(a.toFixed(5))},_valueMin:function(){return this.options.min},_valueMax:function(){return this.options.max},_refreshValue:function(){var a=
+this.options.range,b=this.options,c=this,e=!this._animateOff?b.animate:false,f,g={},h,i,j,k;if(this.options.values&&this.options.values.length)this.handles.each(function(l){f=(c.values(l)-c._valueMin())/(c._valueMax()-c._valueMin())*100;g[c.orientation==="horizontal"?"left":"bottom"]=f+"%";d(this).stop(1,1)[e?"animate":"css"](g,b.animate);if(c.options.range===true)if(c.orientation==="horizontal"){if(l===0)c.range.stop(1,1)[e?"animate":"css"]({left:f+"%"},b.animate);if(l===1)c.range[e?"animate":"css"]({width:f-
+h+"%"},{queue:false,duration:b.animate})}else{if(l===0)c.range.stop(1,1)[e?"animate":"css"]({bottom:f+"%"},b.animate);if(l===1)c.range[e?"animate":"css"]({height:f-h+"%"},{queue:false,duration:b.animate})}h=f});else{i=this.value();j=this._valueMin();k=this._valueMax();f=k!==j?(i-j)/(k-j)*100:0;g[c.orientation==="horizontal"?"left":"bottom"]=f+"%";this.handle.stop(1,1)[e?"animate":"css"](g,b.animate);if(a==="min"&&this.orientation==="horizontal")this.range.stop(1,1)[e?"animate":"css"]({width:f+"%"},
+b.animate);if(a==="max"&&this.orientation==="horizontal")this.range[e?"animate":"css"]({width:100-f+"%"},{queue:false,duration:b.animate});if(a==="min"&&this.orientation==="vertical")this.range.stop(1,1)[e?"animate":"css"]({height:f+"%"},b.animate);if(a==="max"&&this.orientation==="vertical")this.range[e?"animate":"css"]({height:100-f+"%"},{queue:false,duration:b.animate})}}});d.extend(d.ui.slider,{version:"1.8.2"})})(jQuery);
+;/*
+ * jQuery UI Tabs 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Tabs
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ *     jquery.ui.widget.js
+ */
+(function(d){function s(){return++u}function v(){return++w}var u=0,w=0;d.widget("ui.tabs",{options:{add:null,ajaxOptions:null,cache:false,cookie:null,collapsible:false,disable:null,disabled:[],enable:null,event:"click",fx:null,idPrefix:"ui-tabs-",load:null,panelTemplate:"<div></div>",remove:null,select:null,show:null,spinner:"<em>Loading&#8230;</em>",tabTemplate:'<li><a href="#{href}"><span>#{label}</span></a></li>'},_create:function(){this._tabify(true)},_setOption:function(c,e){if(c=="selected")this.options.collapsible&&
+e==this.options.selected||this.select(e);else{this.options[c]=e;this._tabify()}},_tabId:function(c){return c.title&&c.title.replace(/\s/g,"_").replace(/[^A-Za-z0-9\-_:\.]/g,"")||this.options.idPrefix+s()},_sanitizeSelector:function(c){return c.replace(/:/g,"\\:")},_cookie:function(){var c=this.cookie||(this.cookie=this.options.cookie.name||"ui-tabs-"+v());return d.cookie.apply(null,[c].concat(d.makeArray(arguments)))},_ui:function(c,e){return{tab:c,panel:e,index:this.anchors.index(c)}},_cleanup:function(){this.lis.filter(".ui-state-processing").removeClass("ui-state-processing").find("span:data(label.tabs)").each(function(){var c=
+d(this);c.html(c.data("label.tabs")).removeData("label.tabs")})},_tabify:function(c){function e(g,f){g.css({display:""});!d.support.opacity&&f.opacity&&g[0].style.removeAttribute("filter")}this.list=this.element.find("ol,ul").eq(0);this.lis=d("li:has(a[href])",this.list);this.anchors=this.lis.map(function(){return d("a",this)[0]});this.panels=d([]);var a=this,b=this.options,h=/^#.+/;this.anchors.each(function(g,f){var j=d(f).attr("href"),l=j.split("#")[0],p;if(l&&(l===location.toString().split("#")[0]||
+(p=d("base")[0])&&l===p.href)){j=f.hash;f.href=j}if(h.test(j))a.panels=a.panels.add(a._sanitizeSelector(j));else if(j!="#"){d.data(f,"href.tabs",j);d.data(f,"load.tabs",j.replace(/#.*$/,""));j=a._tabId(f);f.href="#"+j;f=d("#"+j);if(!f.length){f=d(b.panelTemplate).attr("id",j).addClass("ui-tabs-panel ui-widget-content ui-corner-bottom").insertAfter(a.panels[g-1]||a.list);f.data("destroy.tabs",true)}a.panels=a.panels.add(f)}else b.disabled.push(g)});if(c){this.element.addClass("ui-tabs ui-widget ui-widget-content ui-corner-all");
+this.list.addClass("ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all");this.lis.addClass("ui-state-default ui-corner-top");this.panels.addClass("ui-tabs-panel ui-widget-content ui-corner-bottom");if(b.selected===undefined){location.hash&&this.anchors.each(function(g,f){if(f.hash==location.hash){b.selected=g;return false}});if(typeof b.selected!="number"&&b.cookie)b.selected=parseInt(a._cookie(),10);if(typeof b.selected!="number"&&this.lis.filter(".ui-tabs-selected").length)b.selected=
+this.lis.index(this.lis.filter(".ui-tabs-selected"));b.selected=b.selected||(this.lis.length?0:-1)}else if(b.selected===null)b.selected=-1;b.selected=b.selected>=0&&this.anchors[b.selected]||b.selected<0?b.selected:0;b.disabled=d.unique(b.disabled.concat(d.map(this.lis.filter(".ui-state-disabled"),function(g){return a.lis.index(g)}))).sort();d.inArray(b.selected,b.disabled)!=-1&&b.disabled.splice(d.inArray(b.selected,b.disabled),1);this.panels.addClass("ui-tabs-hide");this.lis.removeClass("ui-tabs-selected ui-state-active");
+if(b.selected>=0&&this.anchors.length){this.panels.eq(b.selected).removeClass("ui-tabs-hide");this.lis.eq(b.selected).addClass("ui-tabs-selected ui-state-active");a.element.queue("tabs",function(){a._trigger("show",null,a._ui(a.anchors[b.selected],a.panels[b.selected]))});this.load(b.selected)}d(window).bind("unload",function(){a.lis.add(a.anchors).unbind(".tabs");a.lis=a.anchors=a.panels=null})}else b.selected=this.lis.index(this.lis.filter(".ui-tabs-selected"));this.element[b.collapsible?"addClass":
+"removeClass"]("ui-tabs-collapsible");b.cookie&&this._cookie(b.selected,b.cookie);c=0;for(var i;i=this.lis[c];c++)d(i)[d.inArray(c,b.disabled)!=-1&&!d(i).hasClass("ui-tabs-selected")?"addClass":"removeClass"]("ui-state-disabled");b.cache===false&&this.anchors.removeData("cache.tabs");this.lis.add(this.anchors).unbind(".tabs");if(b.event!="mouseover"){var k=function(g,f){f.is(":not(.ui-state-disabled)")&&f.addClass("ui-state-"+g)},n=function(g,f){f.removeClass("ui-state-"+g)};this.lis.bind("mouseover.tabs",
+function(){k("hover",d(this))});this.lis.bind("mouseout.tabs",function(){n("hover",d(this))});this.anchors.bind("focus.tabs",function(){k("focus",d(this).closest("li"))});this.anchors.bind("blur.tabs",function(){n("focus",d(this).closest("li"))})}var m,o;if(b.fx)if(d.isArray(b.fx)){m=b.fx[0];o=b.fx[1]}else m=o=b.fx;var q=o?function(g,f){d(g).closest("li").addClass("ui-tabs-selected ui-state-active");f.hide().removeClass("ui-tabs-hide").animate(o,o.duration||"normal",function(){e(f,o);a._trigger("show",
+null,a._ui(g,f[0]))})}:function(g,f){d(g).closest("li").addClass("ui-tabs-selected ui-state-active");f.removeClass("ui-tabs-hide");a._trigger("show",null,a._ui(g,f[0]))},r=m?function(g,f){f.animate(m,m.duration||"normal",function(){a.lis.removeClass("ui-tabs-selected ui-state-active");f.addClass("ui-tabs-hide");e(f,m);a.element.dequeue("tabs")})}:function(g,f){a.lis.removeClass("ui-tabs-selected ui-state-active");f.addClass("ui-tabs-hide");a.element.dequeue("tabs")};this.anchors.bind(b.event+".tabs",
+function(){var g=this,f=d(this).closest("li"),j=a.panels.filter(":not(.ui-tabs-hide)"),l=d(a._sanitizeSelector(this.hash));if(f.hasClass("ui-tabs-selected")&&!b.collapsible||f.hasClass("ui-state-disabled")||f.hasClass("ui-state-processing")||a._trigger("select",null,a._ui(this,l[0]))===false){this.blur();return false}b.selected=a.anchors.index(this);a.abort();if(b.collapsible)if(f.hasClass("ui-tabs-selected")){b.selected=-1;b.cookie&&a._cookie(b.selected,b.cookie);a.element.queue("tabs",function(){r(g,
+j)}).dequeue("tabs");this.blur();return false}else if(!j.length){b.cookie&&a._cookie(b.selected,b.cookie);a.element.queue("tabs",function(){q(g,l)});a.load(a.anchors.index(this));this.blur();return false}b.cookie&&a._cookie(b.selected,b.cookie);if(l.length){j.length&&a.element.queue("tabs",function(){r(g,j)});a.element.queue("tabs",function(){q(g,l)});a.load(a.anchors.index(this))}else throw"jQuery UI Tabs: Mismatching fragment identifier.";d.browser.msie&&this.blur()});this.anchors.bind("click.tabs",
+function(){return false})},destroy:function(){var c=this.options;this.abort();this.element.unbind(".tabs").removeClass("ui-tabs ui-widget ui-widget-content ui-corner-all ui-tabs-collapsible").removeData("tabs");this.list.removeClass("ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all");this.anchors.each(function(){var e=d.data(this,"href.tabs");if(e)this.href=e;var a=d(this).unbind(".tabs");d.each(["href","load","cache"],function(b,h){a.removeData(h+".tabs")})});this.lis.unbind(".tabs").add(this.panels).each(function(){d.data(this,
+"destroy.tabs")?d(this).remove():d(this).removeClass("ui-state-default ui-corner-top ui-tabs-selected ui-state-active ui-state-hover ui-state-focus ui-state-disabled ui-tabs-panel ui-widget-content ui-corner-bottom ui-tabs-hide")});c.cookie&&this._cookie(null,c.cookie);return this},add:function(c,e,a){if(a===undefined)a=this.anchors.length;var b=this,h=this.options;e=d(h.tabTemplate.replace(/#\{href\}/g,c).replace(/#\{label\}/g,e));c=!c.indexOf("#")?c.replace("#",""):this._tabId(d("a",e)[0]);e.addClass("ui-state-default ui-corner-top").data("destroy.tabs",
+true);var i=d("#"+c);i.length||(i=d(h.panelTemplate).attr("id",c).data("destroy.tabs",true));i.addClass("ui-tabs-panel ui-widget-content ui-corner-bottom ui-tabs-hide");if(a>=this.lis.length){e.appendTo(this.list);i.appendTo(this.list[0].parentNode)}else{e.insertBefore(this.lis[a]);i.insertBefore(this.panels[a])}h.disabled=d.map(h.disabled,function(k){return k>=a?++k:k});this._tabify();if(this.anchors.length==1){h.selected=0;e.addClass("ui-tabs-selected ui-state-active");i.removeClass("ui-tabs-hide");
+this.element.queue("tabs",function(){b._trigger("show",null,b._ui(b.anchors[0],b.panels[0]))});this.load(0)}this._trigger("add",null,this._ui(this.anchors[a],this.panels[a]));return this},remove:function(c){var e=this.options,a=this.lis.eq(c).remove(),b=this.panels.eq(c).remove();if(a.hasClass("ui-tabs-selected")&&this.anchors.length>1)this.select(c+(c+1<this.anchors.length?1:-1));e.disabled=d.map(d.grep(e.disabled,function(h){return h!=c}),function(h){return h>=c?--h:h});this._tabify();this._trigger("remove",
+null,this._ui(a.find("a")[0],b[0]));return this},enable:function(c){var e=this.options;if(d.inArray(c,e.disabled)!=-1){this.lis.eq(c).removeClass("ui-state-disabled");e.disabled=d.grep(e.disabled,function(a){return a!=c});this._trigger("enable",null,this._ui(this.anchors[c],this.panels[c]));return this}},disable:function(c){var e=this.options;if(c!=e.selected){this.lis.eq(c).addClass("ui-state-disabled");e.disabled.push(c);e.disabled.sort();this._trigger("disable",null,this._ui(this.anchors[c],this.panels[c]))}return this},
+select:function(c){if(typeof c=="string")c=this.anchors.index(this.anchors.filter("[href$="+c+"]"));else if(c===null)c=-1;if(c==-1&&this.options.collapsible)c=this.options.selected;this.anchors.eq(c).trigger(this.options.event+".tabs");return this},load:function(c){var e=this,a=this.options,b=this.anchors.eq(c)[0],h=d.data(b,"load.tabs");this.abort();if(!h||this.element.queue("tabs").length!==0&&d.data(b,"cache.tabs"))this.element.dequeue("tabs");else{this.lis.eq(c).addClass("ui-state-processing");
+if(a.spinner){var i=d("span",b);i.data("label.tabs",i.html()).html(a.spinner)}this.xhr=d.ajax(d.extend({},a.ajaxOptions,{url:h,success:function(k,n){d(e._sanitizeSelector(b.hash)).html(k);e._cleanup();a.cache&&d.data(b,"cache.tabs",true);e._trigger("load",null,e._ui(e.anchors[c],e.panels[c]));try{a.ajaxOptions.success(k,n)}catch(m){}},error:function(k,n){e._cleanup();e._trigger("load",null,e._ui(e.anchors[c],e.panels[c]));try{a.ajaxOptions.error(k,n,c,b)}catch(m){}}}));e.element.dequeue("tabs");return this}},
+abort:function(){this.element.queue([]);this.panels.stop(false,true);this.element.queue("tabs",this.element.queue("tabs").splice(-2,2));if(this.xhr){this.xhr.abort();delete this.xhr}this._cleanup();return this},url:function(c,e){this.anchors.eq(c).removeData("cache.tabs").data("load.tabs",e);return this},length:function(){return this.anchors.length}});d.extend(d.ui.tabs,{version:"1.8.2"});d.extend(d.ui.tabs.prototype,{rotation:null,rotate:function(c,e){var a=this,b=this.options,h=a._rotate||(a._rotate=
+function(i){clearTimeout(a.rotation);a.rotation=setTimeout(function(){var k=b.selected;a.select(++k<a.anchors.length?k:0)},c);i&&i.stopPropagation()});e=a._unrotate||(a._unrotate=!e?function(i){i.clientX&&a.rotate(null)}:function(){t=b.selected;h()});if(c){this.element.bind("tabsshow",h);this.anchors.bind(b.event+".tabs",e);h()}else{clearTimeout(a.rotation);this.element.unbind("tabsshow",h);this.anchors.unbind(b.event+".tabs",e);delete this._rotate;delete this._unrotate}return this}})})(jQuery);
+;/*
+ * jQuery UI Datepicker 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Datepicker
+ *
+ * Depends:
+ *     jquery.ui.core.js
+ */
+(function(d){function J(){this.debug=false;this._curInst=null;this._keyEvent=false;this._disabledInputs=[];this._inDialog=this._datepickerShowing=false;this._mainDivId="ui-datepicker-div";this._inlineClass="ui-datepicker-inline";this._appendClass="ui-datepicker-append";this._triggerClass="ui-datepicker-trigger";this._dialogClass="ui-datepicker-dialog";this._disableClass="ui-datepicker-disabled";this._unselectableClass="ui-datepicker-unselectable";this._currentClass="ui-datepicker-current-day";this._dayOverClass=
+"ui-datepicker-days-cell-over";this.regional=[];this.regional[""]={closeText:"Done",prevText:"Prev",nextText:"Next",currentText:"Today",monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su",
+"Mo","Tu","We","Th","Fr","Sa"],weekHeader:"Wk",dateFormat:"mm/dd/yy",firstDay:0,isRTL:false,showMonthAfterYear:false,yearSuffix:""};this._defaults={showOn:"focus",showAnim:"fadeIn",showOptions:{},defaultDate:null,appendText:"",buttonText:"...",buttonImage:"",buttonImageOnly:false,hideIfNoPrevNext:false,navigationAsDateFormat:false,gotoCurrent:false,changeMonth:false,changeYear:false,yearRange:"c-10:c+10",showOtherMonths:false,selectOtherMonths:false,showWeek:false,calculateWeek:this.iso8601Week,shortYearCutoff:"+10",
+minDate:null,maxDate:null,duration:"fast",beforeShowDay:null,beforeShow:null,onSelect:null,onChangeMonthYear:null,onClose:null,numberOfMonths:1,showCurrentAtPos:0,stepMonths:1,stepBigMonths:12,altField:"",altFormat:"",constrainInput:true,showButtonPanel:false,autoSize:false};d.extend(this._defaults,this.regional[""]);this.dpDiv=d('<div id="'+this._mainDivId+'" class="ui-datepicker ui-widget ui-widget-content ui-helper-clearfix ui-corner-all ui-helper-hidden-accessible"></div>')}function E(a,b){d.extend(a,
+b);for(var c in b)if(b[c]==null||b[c]==undefined)a[c]=b[c];return a}d.extend(d.ui,{datepicker:{version:"1.8.2"}});var y=(new Date).getTime();d.extend(J.prototype,{markerClassName:"hasDatepicker",log:function(){this.debug&&console.log.apply("",arguments)},_widgetDatepicker:function(){return this.dpDiv},setDefaults:function(a){E(this._defaults,a||{});return this},_attachDatepicker:function(a,b){var c=null;for(var e in this._defaults){var f=a.getAttribute("date:"+e);if(f){c=c||{};try{c[e]=eval(f)}catch(h){c[e]=
+f}}}e=a.nodeName.toLowerCase();f=e=="div"||e=="span";if(!a.id){this.uuid+=1;a.id="dp"+this.uuid}var i=this._newInst(d(a),f);i.settings=d.extend({},b||{},c||{});if(e=="input")this._connectDatepicker(a,i);else f&&this._inlineDatepicker(a,i)},_newInst:function(a,b){return{id:a[0].id.replace(/([^A-Za-z0-9_])/g,"\\\\$1"),input:a,selectedDay:0,selectedMonth:0,selectedYear:0,drawMonth:0,drawYear:0,inline:b,dpDiv:!b?this.dpDiv:d('<div class="'+this._inlineClass+' ui-datepicker ui-widget ui-widget-content ui-helper-clearfix ui-corner-all"></div>')}},
+_connectDatepicker:function(a,b){var c=d(a);b.append=d([]);b.trigger=d([]);if(!c.hasClass(this.markerClassName)){this._attachments(c,b);c.addClass(this.markerClassName).keydown(this._doKeyDown).keypress(this._doKeyPress).keyup(this._doKeyUp).bind("setData.datepicker",function(e,f,h){b.settings[f]=h}).bind("getData.datepicker",function(e,f){return this._get(b,f)});this._autoSize(b);d.data(a,"datepicker",b)}},_attachments:function(a,b){var c=this._get(b,"appendText"),e=this._get(b,"isRTL");b.append&&
+b.append.remove();if(c){b.append=d('<span class="'+this._appendClass+'">'+c+"</span>");a[e?"before":"after"](b.append)}a.unbind("focus",this._showDatepicker);b.trigger&&b.trigger.remove();c=this._get(b,"showOn");if(c=="focus"||c=="both")a.focus(this._showDatepicker);if(c=="button"||c=="both"){c=this._get(b,"buttonText");var f=this._get(b,"buttonImage");b.trigger=d(this._get(b,"buttonImageOnly")?d("<img/>").addClass(this._triggerClass).attr({src:f,alt:c,title:c}):d('<button type="button"></button>').addClass(this._triggerClass).html(f==
+""?c:d("<img/>").attr({src:f,alt:c,title:c})));a[e?"before":"after"](b.trigger);b.trigger.click(function(){d.datepicker._datepickerShowing&&d.datepicker._lastInput==a[0]?d.datepicker._hideDatepicker():d.datepicker._showDatepicker(a[0]);return false})}},_autoSize:function(a){if(this._get(a,"autoSize")&&!a.inline){var b=new Date(2009,11,20),c=this._get(a,"dateFormat");if(c.match(/[DM]/)){var e=function(f){for(var h=0,i=0,g=0;g<f.length;g++)if(f[g].length>h){h=f[g].length;i=g}return i};b.setMonth(e(this._get(a,
+c.match(/MM/)?"monthNames":"monthNamesShort")));b.setDate(e(this._get(a,c.match(/DD/)?"dayNames":"dayNamesShort"))+20-b.getDay())}a.input.attr("size",this._formatDate(a,b).length)}},_inlineDatepicker:function(a,b){var c=d(a);if(!c.hasClass(this.markerClassName)){c.addClass(this.markerClassName).append(b.dpDiv).bind("setData.datepicker",function(e,f,h){b.settings[f]=h}).bind("getData.datepicker",function(e,f){return this._get(b,f)});d.data(a,"datepicker",b);this._setDate(b,this._getDefaultDate(b),
+true);this._updateDatepicker(b);this._updateAlternate(b)}},_dialogDatepicker:function(a,b,c,e,f){a=this._dialogInst;if(!a){this.uuid+=1;this._dialogInput=d('<input type="text" id="'+("dp"+this.uuid)+'" style="position: absolute; top: -100px; width: 0px; z-index: -10;"/>');this._dialogInput.keydown(this._doKeyDown);d("body").append(this._dialogInput);a=this._dialogInst=this._newInst(this._dialogInput,false);a.settings={};d.data(this._dialogInput[0],"datepicker",a)}E(a.settings,e||{});b=b&&b.constructor==
+Date?this._formatDate(a,b):b;this._dialogInput.val(b);this._pos=f?f.length?f:[f.pageX,f.pageY]:null;if(!this._pos)this._pos=[document.documentElement.clientWidth/2-100+(document.documentElement.scrollLeft||document.body.scrollLeft),document.documentElement.clientHeight/2-150+(document.documentElement.scrollTop||document.body.scrollTop)];this._dialogInput.css("left",this._pos[0]+20+"px").css("top",this._pos[1]+"px");a.settings.onSelect=c;this._inDialog=true;this.dpDiv.addClass(this._dialogClass);this._showDatepicker(this._dialogInput[0]);
+d.blockUI&&d.blockUI(this.dpDiv);d.data(this._dialogInput[0],"datepicker",a);return this},_destroyDatepicker:function(a){var b=d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();d.removeData(a,"datepicker");if(e=="input"){c.append.remove();c.trigger.remove();b.removeClass(this.markerClassName).unbind("focus",this._showDatepicker).unbind("keydown",this._doKeyDown).unbind("keypress",this._doKeyPress).unbind("keyup",this._doKeyUp)}else if(e=="div"||e=="span")b.removeClass(this.markerClassName).empty()}},
+_enableDatepicker:function(a){var b=d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();if(e=="input"){a.disabled=false;c.trigger.filter("button").each(function(){this.disabled=false}).end().filter("img").css({opacity:"1.0",cursor:""})}else if(e=="div"||e=="span")b.children("."+this._inlineClass).children().removeClass("ui-state-disabled");this._disabledInputs=d.map(this._disabledInputs,function(f){return f==a?null:f})}},_disableDatepicker:function(a){var b=
+d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();if(e=="input"){a.disabled=true;c.trigger.filter("button").each(function(){this.disabled=true}).end().filter("img").css({opacity:"0.5",cursor:"default"})}else if(e=="div"||e=="span")b.children("."+this._inlineClass).children().addClass("ui-state-disabled");this._disabledInputs=d.map(this._disabledInputs,function(f){return f==a?null:f});this._disabledInputs[this._disabledInputs.length]=a}},_isDisabledDatepicker:function(a){if(!a)return false;
+for(var b=0;b<this._disabledInputs.length;b++)if(this._disabledInputs[b]==a)return true;return false},_getInst:function(a){try{return d.data(a,"datepicker")}catch(b){throw"Missing instance data for this datepicker";}},_optionDatepicker:function(a,b,c){var e=this._getInst(a);if(arguments.length==2&&typeof b=="string")return b=="defaults"?d.extend({},d.datepicker._defaults):e?b=="all"?d.extend({},e.settings):this._get(e,b):null;var f=b||{};if(typeof b=="string"){f={};f[b]=c}if(e){this._curInst==e&&
+this._hideDatepicker();var h=this._getDateDatepicker(a,true);E(e.settings,f);this._attachments(d(a),e);this._autoSize(e);this._setDateDatepicker(a,h);this._updateDatepicker(e)}},_changeDatepicker:function(a,b,c){this._optionDatepicker(a,b,c)},_refreshDatepicker:function(a){(a=this._getInst(a))&&this._updateDatepicker(a)},_setDateDatepicker:function(a,b){if(a=this._getInst(a)){this._setDate(a,b);this._updateDatepicker(a);this._updateAlternate(a)}},_getDateDatepicker:function(a,b){(a=this._getInst(a))&&
+!a.inline&&this._setDateFromField(a,b);return a?this._getDate(a):null},_doKeyDown:function(a){var b=d.datepicker._getInst(a.target),c=true,e=b.dpDiv.is(".ui-datepicker-rtl");b._keyEvent=true;if(d.datepicker._datepickerShowing)switch(a.keyCode){case 9:d.datepicker._hideDatepicker();c=false;break;case 13:c=d("td."+d.datepicker._dayOverClass,b.dpDiv).add(d("td."+d.datepicker._currentClass,b.dpDiv));c[0]?d.datepicker._selectDay(a.target,b.selectedMonth,b.selectedYear,c[0]):d.datepicker._hideDatepicker();
+return false;case 27:d.datepicker._hideDatepicker();break;case 33:d.datepicker._adjustDate(a.target,a.ctrlKey?-d.datepicker._get(b,"stepBigMonths"):-d.datepicker._get(b,"stepMonths"),"M");break;case 34:d.datepicker._adjustDate(a.target,a.ctrlKey?+d.datepicker._get(b,"stepBigMonths"):+d.datepicker._get(b,"stepMonths"),"M");break;case 35:if(a.ctrlKey||a.metaKey)d.datepicker._clearDate(a.target);c=a.ctrlKey||a.metaKey;break;case 36:if(a.ctrlKey||a.metaKey)d.datepicker._gotoToday(a.target);c=a.ctrlKey||
+a.metaKey;break;case 37:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,e?+1:-1,"D");c=a.ctrlKey||a.metaKey;if(a.originalEvent.altKey)d.datepicker._adjustDate(a.target,a.ctrlKey?-d.datepicker._get(b,"stepBigMonths"):-d.datepicker._get(b,"stepMonths"),"M");break;case 38:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,-7,"D");c=a.ctrlKey||a.metaKey;break;case 39:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,e?-1:+1,"D");c=a.ctrlKey||a.metaKey;if(a.originalEvent.altKey)d.datepicker._adjustDate(a.target,
+a.ctrlKey?+d.datepicker._get(b,"stepBigMonths"):+d.datepicker._get(b,"stepMonths"),"M");break;case 40:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,+7,"D");c=a.ctrlKey||a.metaKey;break;default:c=false}else if(a.keyCode==36&&a.ctrlKey)d.datepicker._showDatepicker(this);else c=false;if(c){a.preventDefault();a.stopPropagation()}},_doKeyPress:function(a){var b=d.datepicker._getInst(a.target);if(d.datepicker._get(b,"constrainInput")){b=d.datepicker._possibleChars(d.datepicker._get(b,"dateFormat"));
+var c=String.fromCharCode(a.charCode==undefined?a.keyCode:a.charCode);return a.ctrlKey||c<" "||!b||b.indexOf(c)>-1}},_doKeyUp:function(a){a=d.datepicker._getInst(a.target);if(a.input.val()!=a.lastVal)try{if(d.datepicker.parseDate(d.datepicker._get(a,"dateFormat"),a.input?a.input.val():null,d.datepicker._getFormatConfig(a))){d.datepicker._setDateFromField(a);d.datepicker._updateAlternate(a);d.datepicker._updateDatepicker(a)}}catch(b){d.datepicker.log(b)}return true},_showDatepicker:function(a){a=a.target||
+a;if(a.nodeName.toLowerCase()!="input")a=d("input",a.parentNode)[0];if(!(d.datepicker._isDisabledDatepicker(a)||d.datepicker._lastInput==a)){var b=d.datepicker._getInst(a);d.datepicker._curInst&&d.datepicker._curInst!=b&&d.datepicker._curInst.dpDiv.stop(true,true);var c=d.datepicker._get(b,"beforeShow");E(b.settings,c?c.apply(a,[a,b]):{});b.lastVal=null;d.datepicker._lastInput=a;d.datepicker._setDateFromField(b);if(d.datepicker._inDialog)a.value="";if(!d.datepicker._pos){d.datepicker._pos=d.datepicker._findPos(a);
+d.datepicker._pos[1]+=a.offsetHeight}var e=false;d(a).parents().each(function(){e|=d(this).css("position")=="fixed";return!e});if(e&&d.browser.opera){d.datepicker._pos[0]-=document.documentElement.scrollLeft;d.datepicker._pos[1]-=document.documentElement.scrollTop}c={left:d.datepicker._pos[0],top:d.datepicker._pos[1]};d.datepicker._pos=null;b.dpDiv.css({position:"absolute",display:"block",top:"-1000px"});d.datepicker._updateDatepicker(b);c=d.datepicker._checkOffset(b,c,e);b.dpDiv.css({position:d.datepicker._inDialog&&
+d.blockUI?"static":e?"fixed":"absolute",display:"none",left:c.left+"px",top:c.top+"px"});if(!b.inline){c=d.datepicker._get(b,"showAnim");var f=d.datepicker._get(b,"duration"),h=function(){d.datepicker._datepickerShowing=true;var i=d.datepicker._getBorders(b.dpDiv);b.dpDiv.find("iframe.ui-datepicker-cover").css({left:-i[0],top:-i[1],width:b.dpDiv.outerWidth(),height:b.dpDiv.outerHeight()})};b.dpDiv.zIndex(d(a).zIndex()+1);d.effects&&d.effects[c]?b.dpDiv.show(c,d.datepicker._get(b,"showOptions"),f,
+h):b.dpDiv[c||"show"](c?f:null,h);if(!c||!f)h();b.input.is(":visible")&&!b.input.is(":disabled")&&b.input.focus();d.datepicker._curInst=b}}},_updateDatepicker:function(a){var b=this,c=d.datepicker._getBorders(a.dpDiv);a.dpDiv.empty().append(this._generateHTML(a)).find("iframe.ui-datepicker-cover").css({left:-c[0],top:-c[1],width:a.dpDiv.outerWidth(),height:a.dpDiv.outerHeight()}).end().find("button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a").bind("mouseout",function(){d(this).removeClass("ui-state-hover");
+this.className.indexOf("ui-datepicker-prev")!=-1&&d(this).removeClass("ui-datepicker-prev-hover");this.className.indexOf("ui-datepicker-next")!=-1&&d(this).removeClass("ui-datepicker-next-hover")}).bind("mouseover",function(){if(!b._isDisabledDatepicker(a.inline?a.dpDiv.parent()[0]:a.input[0])){d(this).parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover");d(this).addClass("ui-state-hover");this.className.indexOf("ui-datepicker-prev")!=-1&&d(this).addClass("ui-datepicker-prev-hover");
+this.className.indexOf("ui-datepicker-next")!=-1&&d(this).addClass("ui-datepicker-next-hover")}}).end().find("."+this._dayOverClass+" a").trigger("mouseover").end();c=this._getNumberOfMonths(a);var e=c[1];e>1?a.dpDiv.addClass("ui-datepicker-multi-"+e).css("width",17*e+"em"):a.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width("");a.dpDiv[(c[0]!=1||c[1]!=1?"add":"remove")+"Class"]("ui-datepicker-multi");a.dpDiv[(this._get(a,"isRTL")?"add":"remove")+"Class"]("ui-datepicker-rtl");
+a==d.datepicker._curInst&&d.datepicker._datepickerShowing&&a.input&&a.input.is(":visible")&&!a.input.is(":disabled")&&a.input.focus()},_getBorders:function(a){var b=function(c){return{thin:1,medium:2,thick:3}[c]||c};return[parseFloat(b(a.css("border-left-width"))),parseFloat(b(a.css("border-top-width")))]},_checkOffset:function(a,b,c){var e=a.dpDiv.outerWidth(),f=a.dpDiv.outerHeight(),h=a.input?a.input.outerWidth():0,i=a.input?a.input.outerHeight():0,g=document.documentElement.clientWidth+d(document).scrollLeft(),
+k=document.documentElement.clientHeight+d(document).scrollTop();b.left-=this._get(a,"isRTL")?e-h:0;b.left-=c&&b.left==a.input.offset().left?d(document).scrollLeft():0;b.top-=c&&b.top==a.input.offset().top+i?d(document).scrollTop():0;b.left-=Math.min(b.left,b.left+e>g&&g>e?Math.abs(b.left+e-g):0);b.top-=Math.min(b.top,b.top+f>k&&k>f?Math.abs(f+i):0);return b},_findPos:function(a){for(var b=this._get(this._getInst(a),"isRTL");a&&(a.type=="hidden"||a.nodeType!=1);)a=a[b?"previousSibling":"nextSibling"];
+a=d(a).offset();return[a.left,a.top]},_hideDatepicker:function(a){var b=this._curInst;if(!(!b||a&&b!=d.data(a,"datepicker")))if(this._datepickerShowing){a=this._get(b,"showAnim");var c=this._get(b,"duration"),e=function(){d.datepicker._tidyDialog(b);this._curInst=null};d.effects&&d.effects[a]?b.dpDiv.hide(a,d.datepicker._get(b,"showOptions"),c,e):b.dpDiv[a=="slideDown"?"slideUp":a=="fadeIn"?"fadeOut":"hide"](a?c:null,e);a||e();if(a=this._get(b,"onClose"))a.apply(b.input?b.input[0]:null,[b.input?b.input.val():
+"",b]);this._datepickerShowing=false;this._lastInput=null;if(this._inDialog){this._dialogInput.css({position:"absolute",left:"0",top:"-100px"});if(d.blockUI){d.unblockUI();d("body").append(this.dpDiv)}}this._inDialog=false}},_tidyDialog:function(a){a.dpDiv.removeClass(this._dialogClass).unbind(".ui-datepicker-calendar")},_checkExternalClick:function(a){if(d.datepicker._curInst){a=d(a.target);a[0].id!=d.datepicker._mainDivId&&a.parents("#"+d.datepicker._mainDivId).length==0&&!a.hasClass(d.datepicker.markerClassName)&&
+!a.hasClass(d.datepicker._triggerClass)&&d.datepicker._datepickerShowing&&!(d.datepicker._inDialog&&d.blockUI)&&d.datepicker._hideDatepicker()}},_adjustDate:function(a,b,c){a=d(a);var e=this._getInst(a[0]);if(!this._isDisabledDatepicker(a[0])){this._adjustInstDate(e,b+(c=="M"?this._get(e,"showCurrentAtPos"):0),c);this._updateDatepicker(e)}},_gotoToday:function(a){a=d(a);var b=this._getInst(a[0]);if(this._get(b,"gotoCurrent")&&b.currentDay){b.selectedDay=b.currentDay;b.drawMonth=b.selectedMonth=b.currentMonth;
+b.drawYear=b.selectedYear=b.currentYear}else{var c=new Date;b.selectedDay=c.getDate();b.drawMonth=b.selectedMonth=c.getMonth();b.drawYear=b.selectedYear=c.getFullYear()}this._notifyChange(b);this._adjustDate(a)},_selectMonthYear:function(a,b,c){a=d(a);var e=this._getInst(a[0]);e._selectingMonthYear=false;e["selected"+(c=="M"?"Month":"Year")]=e["draw"+(c=="M"?"Month":"Year")]=parseInt(b.options[b.selectedIndex].value,10);this._notifyChange(e);this._adjustDate(a)},_clickMonthYear:function(a){a=this._getInst(d(a)[0]);
+a.input&&a._selectingMonthYear&&!d.browser.msie&&a.input.focus();a._selectingMonthYear=!a._selectingMonthYear},_selectDay:function(a,b,c,e){var f=d(a);if(!(d(e).hasClass(this._unselectableClass)||this._isDisabledDatepicker(f[0]))){f=this._getInst(f[0]);f.selectedDay=f.currentDay=d("a",e).html();f.selectedMonth=f.currentMonth=b;f.selectedYear=f.currentYear=c;this._selectDate(a,this._formatDate(f,f.currentDay,f.currentMonth,f.currentYear))}},_clearDate:function(a){a=d(a);this._getInst(a[0]);this._selectDate(a,
+"")},_selectDate:function(a,b){a=this._getInst(d(a)[0]);b=b!=null?b:this._formatDate(a);a.input&&a.input.val(b);this._updateAlternate(a);var c=this._get(a,"onSelect");if(c)c.apply(a.input?a.input[0]:null,[b,a]);else a.input&&a.input.trigger("change");if(a.inline)this._updateDatepicker(a);else{this._hideDatepicker();this._lastInput=a.input[0];typeof a.input[0]!="object"&&a.input.focus();this._lastInput=null}},_updateAlternate:function(a){var b=this._get(a,"altField");if(b){var c=this._get(a,"altFormat")||
+this._get(a,"dateFormat"),e=this._getDate(a),f=this.formatDate(c,e,this._getFormatConfig(a));d(b).each(function(){d(this).val(f)})}},noWeekends:function(a){a=a.getDay();return[a>0&&a<6,""]},iso8601Week:function(a){a=new Date(a.getTime());a.setDate(a.getDate()+4-(a.getDay()||7));var b=a.getTime();a.setMonth(0);a.setDate(1);return Math.floor(Math.round((b-a)/864E5)/7)+1},parseDate:function(a,b,c){if(a==null||b==null)throw"Invalid arguments";b=typeof b=="object"?b.toString():b+"";if(b=="")return null;
+for(var e=(c?c.shortYearCutoff:null)||this._defaults.shortYearCutoff,f=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,h=(c?c.dayNames:null)||this._defaults.dayNames,i=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort,g=(c?c.monthNames:null)||this._defaults.monthNames,k=c=-1,l=-1,u=-1,j=false,o=function(p){(p=z+1<a.length&&a.charAt(z+1)==p)&&z++;return p},m=function(p){o(p);p=new RegExp("^\\d{1,"+(p=="@"?14:p=="!"?20:p=="y"?4:p=="o"?3:2)+"}");p=b.substring(s).match(p);if(!p)throw"Missing number at position "+
+s;s+=p[0].length;return parseInt(p[0],10)},n=function(p,w,G){p=o(p)?G:w;for(w=0;w<p.length;w++)if(b.substr(s,p[w].length)==p[w]){s+=p[w].length;return w+1}throw"Unknown name at position "+s;},r=function(){if(b.charAt(s)!=a.charAt(z))throw"Unexpected literal at position "+s;s++},s=0,z=0;z<a.length;z++)if(j)if(a.charAt(z)=="'"&&!o("'"))j=false;else r();else switch(a.charAt(z)){case "d":l=m("d");break;case "D":n("D",f,h);break;case "o":u=m("o");break;case "m":k=m("m");break;case "M":k=n("M",i,g);break;
+case "y":c=m("y");break;case "@":var v=new Date(m("@"));c=v.getFullYear();k=v.getMonth()+1;l=v.getDate();break;case "!":v=new Date((m("!")-this._ticksTo1970)/1E4);c=v.getFullYear();k=v.getMonth()+1;l=v.getDate();break;case "'":if(o("'"))r();else j=true;break;default:r()}if(c==-1)c=(new Date).getFullYear();else if(c<100)c+=(new Date).getFullYear()-(new Date).getFullYear()%100+(c<=e?0:-100);if(u>-1){k=1;l=u;do{e=this._getDaysInMonth(c,k-1);if(l<=e)break;k++;l-=e}while(1)}v=this._daylightSavingAdjust(new Date(c,
+k-1,l));if(v.getFullYear()!=c||v.getMonth()+1!=k||v.getDate()!=l)throw"Invalid date";return v},ATOM:"yy-mm-dd",COOKIE:"D, dd M yy",ISO_8601:"yy-mm-dd",RFC_822:"D, d M y",RFC_850:"DD, dd-M-y",RFC_1036:"D, d M y",RFC_1123:"D, d M yy",RFC_2822:"D, d M yy",RSS:"D, d M y",TICKS:"!",TIMESTAMP:"@",W3C:"yy-mm-dd",_ticksTo1970:(718685+Math.floor(492.5)-Math.floor(19.7)+Math.floor(4.925))*24*60*60*1E7,formatDate:function(a,b,c){if(!b)return"";var e=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,f=(c?
+c.dayNames:null)||this._defaults.dayNames,h=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort;c=(c?c.monthNames:null)||this._defaults.monthNames;var i=function(o){(o=j+1<a.length&&a.charAt(j+1)==o)&&j++;return o},g=function(o,m,n){m=""+m;if(i(o))for(;m.length<n;)m="0"+m;return m},k=function(o,m,n,r){return i(o)?r[m]:n[m]},l="",u=false;if(b)for(var j=0;j<a.length;j++)if(u)if(a.charAt(j)=="'"&&!i("'"))u=false;else l+=a.charAt(j);else switch(a.charAt(j)){case "d":l+=g("d",b.getDate(),2);break;
+case "D":l+=k("D",b.getDay(),e,f);break;case "o":l+=g("o",(b.getTime()-(new Date(b.getFullYear(),0,0)).getTime())/864E5,3);break;case "m":l+=g("m",b.getMonth()+1,2);break;case "M":l+=k("M",b.getMonth(),h,c);break;case "y":l+=i("y")?b.getFullYear():(b.getYear()%100<10?"0":"")+b.getYear()%100;break;case "@":l+=b.getTime();break;case "!":l+=b.getTime()*1E4+this._ticksTo1970;break;case "'":if(i("'"))l+="'";else u=true;break;default:l+=a.charAt(j)}return l},_possibleChars:function(a){for(var b="",c=false,
+e=function(h){(h=f+1<a.length&&a.charAt(f+1)==h)&&f++;return h},f=0;f<a.length;f++)if(c)if(a.charAt(f)=="'"&&!e("'"))c=false;else b+=a.charAt(f);else switch(a.charAt(f)){case "d":case "m":case "y":case "@":b+="0123456789";break;case "D":case "M":return null;case "'":if(e("'"))b+="'";else c=true;break;default:b+=a.charAt(f)}return b},_get:function(a,b){return a.settings[b]!==undefined?a.settings[b]:this._defaults[b]},_setDateFromField:function(a,b){if(a.input.val()!=a.lastVal){var c=this._get(a,"dateFormat"),
+e=a.lastVal=a.input?a.input.val():null,f,h;f=h=this._getDefaultDate(a);var i=this._getFormatConfig(a);try{f=this.parseDate(c,e,i)||h}catch(g){this.log(g);e=b?"":e}a.selectedDay=f.getDate();a.drawMonth=a.selectedMonth=f.getMonth();a.drawYear=a.selectedYear=f.getFullYear();a.currentDay=e?f.getDate():0;a.currentMonth=e?f.getMonth():0;a.currentYear=e?f.getFullYear():0;this._adjustInstDate(a)}},_getDefaultDate:function(a){return this._restrictMinMax(a,this._determineDate(a,this._get(a,"defaultDate"),new Date))},
+_determineDate:function(a,b,c){var e=function(h){var i=new Date;i.setDate(i.getDate()+h);return i},f=function(h){try{return d.datepicker.parseDate(d.datepicker._get(a,"dateFormat"),h,d.datepicker._getFormatConfig(a))}catch(i){}var g=(h.toLowerCase().match(/^c/)?d.datepicker._getDate(a):null)||new Date,k=g.getFullYear(),l=g.getMonth();g=g.getDate();for(var u=/([+-]?[0-9]+)\s*(d|D|w|W|m|M|y|Y)?/g,j=u.exec(h);j;){switch(j[2]||"d"){case "d":case "D":g+=parseInt(j[1],10);break;case "w":case "W":g+=parseInt(j[1],
+10)*7;break;case "m":case "M":l+=parseInt(j[1],10);g=Math.min(g,d.datepicker._getDaysInMonth(k,l));break;case "y":case "Y":k+=parseInt(j[1],10);g=Math.min(g,d.datepicker._getDaysInMonth(k,l));break}j=u.exec(h)}return new Date(k,l,g)};if(b=(b=b==null?c:typeof b=="string"?f(b):typeof b=="number"?isNaN(b)?c:e(b):b)&&b.toString()=="Invalid Date"?c:b){b.setHours(0);b.setMinutes(0);b.setSeconds(0);b.setMilliseconds(0)}return this._daylightSavingAdjust(b)},_daylightSavingAdjust:function(a){if(!a)return null;
+a.setHours(a.getHours()>12?a.getHours()+2:0);return a},_setDate:function(a,b,c){var e=!b,f=a.selectedMonth,h=a.selectedYear;b=this._restrictMinMax(a,this._determineDate(a,b,new Date));a.selectedDay=a.currentDay=b.getDate();a.drawMonth=a.selectedMonth=a.currentMonth=b.getMonth();a.drawYear=a.selectedYear=a.currentYear=b.getFullYear();if((f!=a.selectedMonth||h!=a.selectedYear)&&!c)this._notifyChange(a);this._adjustInstDate(a);if(a.input)a.input.val(e?"":this._formatDate(a))},_getDate:function(a){return!a.currentYear||
+a.input&&a.input.val()==""?null:this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay))},_generateHTML:function(a){var b=new Date;b=this._daylightSavingAdjust(new Date(b.getFullYear(),b.getMonth(),b.getDate()));var c=this._get(a,"isRTL"),e=this._get(a,"showButtonPanel"),f=this._get(a,"hideIfNoPrevNext"),h=this._get(a,"navigationAsDateFormat"),i=this._getNumberOfMonths(a),g=this._get(a,"showCurrentAtPos"),k=this._get(a,"stepMonths"),l=i[0]!=1||i[1]!=1,u=this._daylightSavingAdjust(!a.currentDay?
+new Date(9999,9,9):new Date(a.currentYear,a.currentMonth,a.currentDay)),j=this._getMinMaxDate(a,"min"),o=this._getMinMaxDate(a,"max");g=a.drawMonth-g;var m=a.drawYear;if(g<0){g+=12;m--}if(o){var n=this._daylightSavingAdjust(new Date(o.getFullYear(),o.getMonth()-i[0]*i[1]+1,o.getDate()));for(n=j&&n<j?j:n;this._daylightSavingAdjust(new Date(m,g,1))>n;){g--;if(g<0){g=11;m--}}}a.drawMonth=g;a.drawYear=m;n=this._get(a,"prevText");n=!h?n:this.formatDate(n,this._daylightSavingAdjust(new Date(m,g-k,1)),this._getFormatConfig(a));
+n=this._canAdjustMonth(a,-1,m,g)?'<a class="ui-datepicker-prev ui-corner-all" onclick="DP_jQuery_'+y+".datepicker._adjustDate('#"+a.id+"', -"+k+", 'M');\" title=\""+n+'"><span class="ui-icon ui-icon-circle-triangle-'+(c?"e":"w")+'">'+n+"</span></a>":f?"":'<a class="ui-datepicker-prev ui-corner-all ui-state-disabled" title="'+n+'"><span class="ui-icon ui-icon-circle-triangle-'+(c?"e":"w")+'">'+n+"</span></a>";var r=this._get(a,"nextText");r=!h?r:this.formatDate(r,this._daylightSavingAdjust(new Date(m,
+g+k,1)),this._getFormatConfig(a));f=this._canAdjustMonth(a,+1,m,g)?'<a class="ui-datepicker-next ui-corner-all" onclick="DP_jQuery_'+y+".datepicker._adjustDate('#"+a.id+"', +"+k+", 'M');\" title=\""+r+'"><span class="ui-icon ui-icon-circle-triangle-'+(c?"w":"e")+'">'+r+"</span></a>":f?"":'<a class="ui-datepicker-next ui-corner-all ui-state-disabled" title="'+r+'"><span class="ui-icon ui-icon-circle-triangle-'+(c?"w":"e")+'">'+r+"</span></a>";k=this._get(a,"currentText");r=this._get(a,"gotoCurrent")&&
+a.currentDay?u:b;k=!h?k:this.formatDate(k,r,this._getFormatConfig(a));h=!a.inline?'<button type="button" class="ui-datepicker-close ui-state-default ui-priority-primary ui-corner-all" onclick="DP_jQuery_'+y+'.datepicker._hideDatepicker();">'+this._get(a,"closeText")+"</button>":"";e=e?'<div class="ui-datepicker-buttonpane ui-widget-content">'+(c?h:"")+(this._isInRange(a,r)?'<button type="button" class="ui-datepicker-current ui-state-default ui-priority-secondary ui-corner-all" onclick="DP_jQuery_'+
+y+".datepicker._gotoToday('#"+a.id+"');\">"+k+"</button>":"")+(c?"":h)+"</div>":"";h=parseInt(this._get(a,"firstDay"),10);h=isNaN(h)?0:h;k=this._get(a,"showWeek");r=this._get(a,"dayNames");this._get(a,"dayNamesShort");var s=this._get(a,"dayNamesMin"),z=this._get(a,"monthNames"),v=this._get(a,"monthNamesShort"),p=this._get(a,"beforeShowDay"),w=this._get(a,"showOtherMonths"),G=this._get(a,"selectOtherMonths");this._get(a,"calculateWeek");for(var K=this._getDefaultDate(a),H="",C=0;C<i[0];C++){for(var L=
+"",D=0;D<i[1];D++){var M=this._daylightSavingAdjust(new Date(m,g,a.selectedDay)),t=" ui-corner-all",x="";if(l){x+='<div class="ui-datepicker-group';if(i[1]>1)switch(D){case 0:x+=" ui-datepicker-group-first";t=" ui-corner-"+(c?"right":"left");break;case i[1]-1:x+=" ui-datepicker-group-last";t=" ui-corner-"+(c?"left":"right");break;default:x+=" ui-datepicker-group-middle";t="";break}x+='">'}x+='<div class="ui-datepicker-header ui-widget-header ui-helper-clearfix'+t+'">'+(/all|left/.test(t)&&C==0?c?
+f:n:"")+(/all|right/.test(t)&&C==0?c?n:f:"")+this._generateMonthYearHeader(a,g,m,j,o,C>0||D>0,z,v)+'</div><table class="ui-datepicker-calendar"><thead><tr>';var A=k?'<th class="ui-datepicker-week-col">'+this._get(a,"weekHeader")+"</th>":"";for(t=0;t<7;t++){var q=(t+h)%7;A+="<th"+((t+h+6)%7>=5?' class="ui-datepicker-week-end"':"")+'><span title="'+r[q]+'">'+s[q]+"</span></th>"}x+=A+"</tr></thead><tbody>";A=this._getDaysInMonth(m,g);if(m==a.selectedYear&&g==a.selectedMonth)a.selectedDay=Math.min(a.selectedDay,
+A);t=(this._getFirstDayOfMonth(m,g)-h+7)%7;A=l?6:Math.ceil((t+A)/7);q=this._daylightSavingAdjust(new Date(m,g,1-t));for(var N=0;N<A;N++){x+="<tr>";var O=!k?"":'<td class="ui-datepicker-week-col">'+this._get(a,"calculateWeek")(q)+"</td>";for(t=0;t<7;t++){var F=p?p.apply(a.input?a.input[0]:null,[q]):[true,""],B=q.getMonth()!=g,I=B&&!G||!F[0]||j&&q<j||o&&q>o;O+='<td class="'+((t+h+6)%7>=5?" ui-datepicker-week-end":"")+(B?" ui-datepicker-other-month":"")+(q.getTime()==M.getTime()&&g==a.selectedMonth&&
+a._keyEvent||K.getTime()==q.getTime()&&K.getTime()==M.getTime()?" "+this._dayOverClass:"")+(I?" "+this._unselectableClass+" ui-state-disabled":"")+(B&&!w?"":" "+F[1]+(q.getTime()==u.getTime()?" "+this._currentClass:"")+(q.getTime()==b.getTime()?" ui-datepicker-today":""))+'"'+((!B||w)&&F[2]?' title="'+F[2]+'"':"")+(I?"":' onclick="DP_jQuery_'+y+".datepicker._selectDay('#"+a.id+"',"+q.getMonth()+","+q.getFullYear()+', this);return false;"')+">"+(B&&!w?"&#xa0;":I?'<span class="ui-state-default">'+q.getDate()+
+"</span>":'<a class="ui-state-default'+(q.getTime()==b.getTime()?" ui-state-highlight":"")+(q.getTime()==u.getTime()?" ui-state-active":"")+(B?" ui-priority-secondary":"")+'" href="#">'+q.getDate()+"</a>")+"</td>";q.setDate(q.getDate()+1);q=this._daylightSavingAdjust(q)}x+=O+"</tr>"}g++;if(g>11){g=0;m++}x+="</tbody></table>"+(l?"</div>"+(i[0]>0&&D==i[1]-1?'<div class="ui-datepicker-row-break"></div>':""):"");L+=x}H+=L}H+=e+(d.browser.msie&&parseInt(d.browser.version,10)<7&&!a.inline?'<iframe src="javascript:false;" class="ui-datepicker-cover" frameborder="0"></iframe>':
+"");a._keyEvent=false;return H},_generateMonthYearHeader:function(a,b,c,e,f,h,i,g){var k=this._get(a,"changeMonth"),l=this._get(a,"changeYear"),u=this._get(a,"showMonthAfterYear"),j='<div class="ui-datepicker-title">',o="";if(h||!k)o+='<span class="ui-datepicker-month">'+i[b]+"</span>";else{i=e&&e.getFullYear()==c;var m=f&&f.getFullYear()==c;o+='<select class="ui-datepicker-month" onchange="DP_jQuery_'+y+".datepicker._selectMonthYear('#"+a.id+"', this, 'M');\" onclick=\"DP_jQuery_"+y+".datepicker._clickMonthYear('#"+
+a.id+"');\">";for(var n=0;n<12;n++)if((!i||n>=e.getMonth())&&(!m||n<=f.getMonth()))o+='<option value="'+n+'"'+(n==b?' selected="selected"':"")+">"+g[n]+"</option>";o+="</select>"}u||(j+=o+(h||!(k&&l)?"&#xa0;":""));if(h||!l)j+='<span class="ui-datepicker-year">'+c+"</span>";else{g=this._get(a,"yearRange").split(":");var r=(new Date).getFullYear();i=function(s){s=s.match(/c[+-].*/)?c+parseInt(s.substring(1),10):s.match(/[+-].*/)?r+parseInt(s,10):parseInt(s,10);return isNaN(s)?r:s};b=i(g[0]);g=Math.max(b,
+i(g[1]||""));b=e?Math.max(b,e.getFullYear()):b;g=f?Math.min(g,f.getFullYear()):g;for(j+='<select class="ui-datepicker-year" onchange="DP_jQuery_'+y+".datepicker._selectMonthYear('#"+a.id+"', this, 'Y');\" onclick=\"DP_jQuery_"+y+".datepicker._clickMonthYear('#"+a.id+"');\">";b<=g;b++)j+='<option value="'+b+'"'+(b==c?' selected="selected"':"")+">"+b+"</option>";j+="</select>"}j+=this._get(a,"yearSuffix");if(u)j+=(h||!(k&&l)?"&#xa0;":"")+o;j+="</div>";return j},_adjustInstDate:function(a,b,c){var e=
+a.drawYear+(c=="Y"?b:0),f=a.drawMonth+(c=="M"?b:0);b=Math.min(a.selectedDay,this._getDaysInMonth(e,f))+(c=="D"?b:0);e=this._restrictMinMax(a,this._daylightSavingAdjust(new Date(e,f,b)));a.selectedDay=e.getDate();a.drawMonth=a.selectedMonth=e.getMonth();a.drawYear=a.selectedYear=e.getFullYear();if(c=="M"||c=="Y")this._notifyChange(a)},_restrictMinMax:function(a,b){var c=this._getMinMaxDate(a,"min");a=this._getMinMaxDate(a,"max");b=c&&b<c?c:b;return b=a&&b>a?a:b},_notifyChange:function(a){var b=this._get(a,
+"onChangeMonthYear");if(b)b.apply(a.input?a.input[0]:null,[a.selectedYear,a.selectedMonth+1,a])},_getNumberOfMonths:function(a){a=this._get(a,"numberOfMonths");return a==null?[1,1]:typeof a=="number"?[1,a]:a},_getMinMaxDate:function(a,b){return this._determineDate(a,this._get(a,b+"Date"),null)},_getDaysInMonth:function(a,b){return 32-(new Date(a,b,32)).getDate()},_getFirstDayOfMonth:function(a,b){return(new Date(a,b,1)).getDay()},_canAdjustMonth:function(a,b,c,e){var f=this._getNumberOfMonths(a);
+c=this._daylightSavingAdjust(new Date(c,e+(b<0?b:f[0]*f[1]),1));b<0&&c.setDate(this._getDaysInMonth(c.getFullYear(),c.getMonth()));return this._isInRange(a,c)},_isInRange:function(a,b){var c=this._getMinMaxDate(a,"min");a=this._getMinMaxDate(a,"max");return(!c||b.getTime()>=c.getTime())&&(!a||b.getTime()<=a.getTime())},_getFormatConfig:function(a){var b=this._get(a,"shortYearCutoff");b=typeof b!="string"?b:(new Date).getFullYear()%100+parseInt(b,10);return{shortYearCutoff:b,dayNamesShort:this._get(a,
+"dayNamesShort"),dayNames:this._get(a,"dayNames"),monthNamesShort:this._get(a,"monthNamesShort"),monthNames:this._get(a,"monthNames")}},_formatDate:function(a,b,c,e){if(!b){a.currentDay=a.selectedDay;a.currentMonth=a.selectedMonth;a.currentYear=a.selectedYear}b=b?typeof b=="object"?b:this._daylightSavingAdjust(new Date(e,c,b)):this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay));return this.formatDate(this._get(a,"dateFormat"),b,this._getFormatConfig(a))}});d.fn.datepicker=
+function(a){if(!d.datepicker.initialized){d(document).mousedown(d.datepicker._checkExternalClick).find("body").append(d.datepicker.dpDiv);d.datepicker.initialized=true}var b=Array.prototype.slice.call(arguments,1);if(typeof a=="string"&&(a=="isDisabled"||a=="getDate"||a=="widget"))return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b));if(a=="option"&&arguments.length==2&&typeof arguments[1]=="string")return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b));
+return this.each(function(){typeof a=="string"?d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this].concat(b)):d.datepicker._attachDatepicker(this,a)})};d.datepicker=new J;d.datepicker.initialized=false;d.datepicker.uuid=(new Date).getTime();d.datepicker.version="1.8.2";window["DP_jQuery_"+y]=d})(jQuery);
+;/*
+ * jQuery UI Progressbar 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Progressbar
+ *
+ * Depends:
+ *   jquery.ui.core.js
+ *   jquery.ui.widget.js
+ */
+(function(b){b.widget("ui.progressbar",{options:{value:0},_create:function(){this.element.addClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").attr({role:"progressbar","aria-valuemin":this._valueMin(),"aria-valuemax":this._valueMax(),"aria-valuenow":this._value()});this.valueDiv=b("<div class='ui-progressbar-value ui-widget-header ui-corner-left'></div>").appendTo(this.element);this._refreshValue()},destroy:function(){this.element.removeClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").removeAttr("role").removeAttr("aria-valuemin").removeAttr("aria-valuemax").removeAttr("aria-valuenow");
+this.valueDiv.remove();b.Widget.prototype.destroy.apply(this,arguments)},value:function(a){if(a===undefined)return this._value();this._setOption("value",a);return this},_setOption:function(a,c){switch(a){case "value":this.options.value=c;this._refreshValue();this._trigger("change");break}b.Widget.prototype._setOption.apply(this,arguments)},_value:function(){var a=this.options.value;if(typeof a!=="number")a=0;if(a<this._valueMin())a=this._valueMin();if(a>this._valueMax())a=this._valueMax();return a},
+_valueMin:function(){return 0},_valueMax:function(){return 100},_refreshValue:function(){var a=this.value();this.valueDiv[a===this._valueMax()?"addClass":"removeClass"]("ui-corner-right").width(a+"%");this.element.attr("aria-valuenow",a)}});b.extend(b.ui.progressbar,{version:"1.8.2"})})(jQuery);
+;/*
+ * jQuery UI Effects 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/
+ */
+jQuery.effects||function(f){function k(c){var a;if(c&&c.constructor==Array&&c.length==3)return c;if(a=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(c))return[parseInt(a[1],10),parseInt(a[2],10),parseInt(a[3],10)];if(a=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(c))return[parseFloat(a[1])*2.55,parseFloat(a[2])*2.55,parseFloat(a[3])*2.55];if(a=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(c))return[parseInt(a[1],
+16),parseInt(a[2],16),parseInt(a[3],16)];if(a=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(c))return[parseInt(a[1]+a[1],16),parseInt(a[2]+a[2],16),parseInt(a[3]+a[3],16)];if(/rgba\(0, 0, 0, 0\)/.exec(c))return l.transparent;return l[f.trim(c).toLowerCase()]}function q(c,a){var b;do{b=f.curCSS(c,a);if(b!=""&&b!="transparent"||f.nodeName(c,"body"))break;a="backgroundColor"}while(c=c.parentNode);return k(b)}function m(){var c=document.defaultView?document.defaultView.getComputedStyle(this,null):this.currentStyle,
+a={},b,d;if(c&&c.length&&c[0]&&c[c[0]])for(var e=c.length;e--;){b=c[e];if(typeof c[b]=="string"){d=b.replace(/\-(\w)/g,function(g,h){return h.toUpperCase()});a[d]=c[b]}}else for(b in c)if(typeof c[b]==="string")a[b]=c[b];return a}function n(c){var a,b;for(a in c){b=c[a];if(b==null||f.isFunction(b)||a in r||/scrollbar/.test(a)||!/color/i.test(a)&&isNaN(parseFloat(b)))delete c[a]}return c}function s(c,a){var b={_:0},d;for(d in a)if(c[d]!=a[d])b[d]=a[d];return b}function j(c,a,b,d){if(typeof c=="object"){d=
+a;b=null;a=c;c=a.effect}if(f.isFunction(a)){d=a;b=null;a={}}if(f.isFunction(b)){d=b;b=null}if(typeof a=="number"||f.fx.speeds[a]){d=b;b=a;a={}}a=a||{};b=b||a.duration;b=f.fx.off?0:typeof b=="number"?b:f.fx.speeds[b]||f.fx.speeds._default;d=d||a.complete;return[c,a,b,d]}f.effects={};f.each(["backgroundColor","borderBottomColor","borderLeftColor","borderRightColor","borderTopColor","color","outlineColor"],function(c,a){f.fx.step[a]=function(b){if(!b.colorInit){b.start=q(b.elem,a);b.end=k(b.end);b.colorInit=
+true}b.elem.style[a]="rgb("+Math.max(Math.min(parseInt(b.pos*(b.end[0]-b.start[0])+b.start[0],10),255),0)+","+Math.max(Math.min(parseInt(b.pos*(b.end[1]-b.start[1])+b.start[1],10),255),0)+","+Math.max(Math.min(parseInt(b.pos*(b.end[2]-b.start[2])+b.start[2],10),255),0)+")"}});var l={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,
+183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,
+165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0],transparent:[255,255,255]},o=["add","remove","toggle"],r={border:1,borderBottom:1,borderColor:1,borderLeft:1,borderRight:1,borderTop:1,borderWidth:1,margin:1,padding:1};f.effects.animateClass=function(c,a,b,d){if(f.isFunction(b)){d=b;b=null}return this.each(function(){var e=f(this),g=e.attr("style")||" ",h=n(m.call(this)),p,t=e.attr("className");f.each(o,function(u,
+i){c[i]&&e[i+"Class"](c[i])});p=n(m.call(this));e.attr("className",t);e.animate(s(h,p),a,b,function(){f.each(o,function(u,i){c[i]&&e[i+"Class"](c[i])});if(typeof e.attr("style")=="object"){e.attr("style").cssText="";e.attr("style").cssText=g}else e.attr("style",g);d&&d.apply(this,arguments)})})};f.fn.extend({_addClass:f.fn.addClass,addClass:function(c,a,b,d){return a?f.effects.animateClass.apply(this,[{add:c},a,b,d]):this._addClass(c)},_removeClass:f.fn.removeClass,removeClass:function(c,a,b,d){return a?
+f.effects.animateClass.apply(this,[{remove:c},a,b,d]):this._removeClass(c)},_toggleClass:f.fn.toggleClass,toggleClass:function(c,a,b,d,e){return typeof a=="boolean"||a===undefined?b?f.effects.animateClass.apply(this,[a?{add:c}:{remove:c},b,d,e]):this._toggleClass(c,a):f.effects.animateClass.apply(this,[{toggle:c},a,b,d])},switchClass:function(c,a,b,d,e){return f.effects.animateClass.apply(this,[{add:a,remove:c},b,d,e])}});f.extend(f.effects,{version:"1.8.2",save:function(c,a){for(var b=0;b<a.length;b++)a[b]!==
+null&&c.data("ec.storage."+a[b],c[0].style[a[b]])},restore:function(c,a){for(var b=0;b<a.length;b++)a[b]!==null&&c.css(a[b],c.data("ec.storage."+a[b]))},setMode:function(c,a){if(a=="toggle")a=c.is(":hidden")?"show":"hide";return a},getBaseline:function(c,a){var b;switch(c[0]){case "top":b=0;break;case "middle":b=0.5;break;case "bottom":b=1;break;default:b=c[0]/a.height}switch(c[1]){case "left":c=0;break;case "center":c=0.5;break;case "right":c=1;break;default:c=c[1]/a.width}return{x:c,y:b}},createWrapper:function(c){if(c.parent().is(".ui-effects-wrapper"))return c.parent();
+var a={width:c.outerWidth(true),height:c.outerHeight(true),"float":c.css("float")},b=f("<div></div>").addClass("ui-effects-wrapper").css({fontSize:"100%",background:"transparent",border:"none",margin:0,padding:0});c.wrap(b);b=c.parent();if(c.css("position")=="static"){b.css({position:"relative"});c.css({position:"relative"})}else{f.extend(a,{position:c.css("position"),zIndex:c.css("z-index")});f.each(["top","left","bottom","right"],function(d,e){a[e]=c.css(e);if(isNaN(parseInt(a[e],10)))a[e]="auto"});
+c.css({position:"relative",top:0,left:0})}return b.css(a).show()},removeWrapper:function(c){if(c.parent().is(".ui-effects-wrapper"))return c.parent().replaceWith(c);return c},setTransition:function(c,a,b,d){d=d||{};f.each(a,function(e,g){unit=c.cssUnit(g);if(unit[0]>0)d[g]=unit[0]*b+unit[1]});return d}});f.fn.extend({effect:function(c){var a=j.apply(this,arguments);a={options:a[1],duration:a[2],callback:a[3]};var b=f.effects[c];return b&&!f.fx.off?b.call(this,a):this},_show:f.fn.show,show:function(c){if(!c||
+typeof c=="number"||f.fx.speeds[c])return this._show.apply(this,arguments);else{var a=j.apply(this,arguments);a[1].mode="show";return this.effect.apply(this,a)}},_hide:f.fn.hide,hide:function(c){if(!c||typeof c=="number"||f.fx.speeds[c])return this._hide.apply(this,arguments);else{var a=j.apply(this,arguments);a[1].mode="hide";return this.effect.apply(this,a)}},__toggle:f.fn.toggle,toggle:function(c){if(!c||typeof c=="number"||f.fx.speeds[c]||typeof c=="boolean"||f.isFunction(c))return this.__toggle.apply(this,
+arguments);else{var a=j.apply(this,arguments);a[1].mode="toggle";return this.effect.apply(this,a)}},cssUnit:function(c){var a=this.css(c),b=[];f.each(["em","px","%","pt"],function(d,e){if(a.indexOf(e)>0)b=[parseFloat(a),e]});return b}});f.easing.jswing=f.easing.swing;f.extend(f.easing,{def:"easeOutQuad",swing:function(c,a,b,d,e){return f.easing[f.easing.def](c,a,b,d,e)},easeInQuad:function(c,a,b,d,e){return d*(a/=e)*a+b},easeOutQuad:function(c,a,b,d,e){return-d*(a/=e)*(a-2)+b},easeInOutQuad:function(c,
+a,b,d,e){if((a/=e/2)<1)return d/2*a*a+b;return-d/2*(--a*(a-2)-1)+b},easeInCubic:function(c,a,b,d,e){return d*(a/=e)*a*a+b},easeOutCubic:function(c,a,b,d,e){return d*((a=a/e-1)*a*a+1)+b},easeInOutCubic:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a+b;return d/2*((a-=2)*a*a+2)+b},easeInQuart:function(c,a,b,d,e){return d*(a/=e)*a*a*a+b},easeOutQuart:function(c,a,b,d,e){return-d*((a=a/e-1)*a*a*a-1)+b},easeInOutQuart:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a*a+b;return-d/2*((a-=2)*a*a*a-2)+
+b},easeInQuint:function(c,a,b,d,e){return d*(a/=e)*a*a*a*a+b},easeOutQuint:function(c,a,b,d,e){return d*((a=a/e-1)*a*a*a*a+1)+b},easeInOutQuint:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a*a*a+b;return d/2*((a-=2)*a*a*a*a+2)+b},easeInSine:function(c,a,b,d,e){return-d*Math.cos(a/e*(Math.PI/2))+d+b},easeOutSine:function(c,a,b,d,e){return d*Math.sin(a/e*(Math.PI/2))+b},easeInOutSine:function(c,a,b,d,e){return-d/2*(Math.cos(Math.PI*a/e)-1)+b},easeInExpo:function(c,a,b,d,e){return a==0?b:d*Math.pow(2,
+10*(a/e-1))+b},easeOutExpo:function(c,a,b,d,e){return a==e?b+d:d*(-Math.pow(2,-10*a/e)+1)+b},easeInOutExpo:function(c,a,b,d,e){if(a==0)return b;if(a==e)return b+d;if((a/=e/2)<1)return d/2*Math.pow(2,10*(a-1))+b;return d/2*(-Math.pow(2,-10*--a)+2)+b},easeInCirc:function(c,a,b,d,e){return-d*(Math.sqrt(1-(a/=e)*a)-1)+b},easeOutCirc:function(c,a,b,d,e){return d*Math.sqrt(1-(a=a/e-1)*a)+b},easeInOutCirc:function(c,a,b,d,e){if((a/=e/2)<1)return-d/2*(Math.sqrt(1-a*a)-1)+b;return d/2*(Math.sqrt(1-(a-=2)*
+a)+1)+b},easeInElastic:function(c,a,b,d,e){c=1.70158;var g=0,h=d;if(a==0)return b;if((a/=e)==1)return b+d;g||(g=e*0.3);if(h<Math.abs(d)){h=d;c=g/4}else c=g/(2*Math.PI)*Math.asin(d/h);return-(h*Math.pow(2,10*(a-=1))*Math.sin((a*e-c)*2*Math.PI/g))+b},easeOutElastic:function(c,a,b,d,e){c=1.70158;var g=0,h=d;if(a==0)return b;if((a/=e)==1)return b+d;g||(g=e*0.3);if(h<Math.abs(d)){h=d;c=g/4}else c=g/(2*Math.PI)*Math.asin(d/h);return h*Math.pow(2,-10*a)*Math.sin((a*e-c)*2*Math.PI/g)+d+b},easeInOutElastic:function(c,
+a,b,d,e){c=1.70158;var g=0,h=d;if(a==0)return b;if((a/=e/2)==2)return b+d;g||(g=e*0.3*1.5);if(h<Math.abs(d)){h=d;c=g/4}else c=g/(2*Math.PI)*Math.asin(d/h);if(a<1)return-0.5*h*Math.pow(2,10*(a-=1))*Math.sin((a*e-c)*2*Math.PI/g)+b;return h*Math.pow(2,-10*(a-=1))*Math.sin((a*e-c)*2*Math.PI/g)*0.5+d+b},easeInBack:function(c,a,b,d,e,g){if(g==undefined)g=1.70158;return d*(a/=e)*a*((g+1)*a-g)+b},easeOutBack:function(c,a,b,d,e,g){if(g==undefined)g=1.70158;return d*((a=a/e-1)*a*((g+1)*a+g)+1)+b},easeInOutBack:function(c,
+a,b,d,e,g){if(g==undefined)g=1.70158;if((a/=e/2)<1)return d/2*a*a*(((g*=1.525)+1)*a-g)+b;return d/2*((a-=2)*a*(((g*=1.525)+1)*a+g)+2)+b},easeInBounce:function(c,a,b,d,e){return d-f.easing.easeOutBounce(c,e-a,0,d,e)+b},easeOutBounce:function(c,a,b,d,e){return(a/=e)<1/2.75?d*7.5625*a*a+b:a<2/2.75?d*(7.5625*(a-=1.5/2.75)*a+0.75)+b:a<2.5/2.75?d*(7.5625*(a-=2.25/2.75)*a+0.9375)+b:d*(7.5625*(a-=2.625/2.75)*a+0.984375)+b},easeInOutBounce:function(c,a,b,d,e){if(a<e/2)return f.easing.easeInBounce(c,a*2,0,
+d,e)*0.5+b;return f.easing.easeOutBounce(c,a*2-e,0,d,e)*0.5+d*0.5+b}})}(jQuery);
+;/*
+ * jQuery UI Effects Blind 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Blind
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(b){b.effects.blind=function(c){return this.queue(function(){var a=b(this),g=["position","top","left"],f=b.effects.setMode(a,c.options.mode||"hide"),d=c.options.direction||"vertical";b.effects.save(a,g);a.show();var e=b.effects.createWrapper(a).css({overflow:"hidden"}),h=d=="vertical"?"height":"width";d=d=="vertical"?e.height():e.width();f=="show"&&e.css(h,0);var i={};i[h]=f=="show"?d:0;e.animate(i,c.duration,c.options.easing,function(){f=="hide"&&a.hide();b.effects.restore(a,g);b.effects.removeWrapper(a);
+c.callback&&c.callback.apply(a[0],arguments);a.dequeue()})})}})(jQuery);
+;/*
+ * jQuery UI Effects Bounce 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Bounce
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(e){e.effects.bounce=function(b){return this.queue(function(){var a=e(this),l=["position","top","left"],h=e.effects.setMode(a,b.options.mode||"effect"),d=b.options.direction||"up",c=b.options.distance||20,m=b.options.times||5,i=b.duration||250;/show|hide/.test(h)&&l.push("opacity");e.effects.save(a,l);a.show();e.effects.createWrapper(a);var f=d=="up"||d=="down"?"top":"left";d=d=="up"||d=="left"?"pos":"neg";c=b.options.distance||(f=="top"?a.outerHeight({margin:true})/3:a.outerWidth({margin:true})/
+3);if(h=="show")a.css("opacity",0).css(f,d=="pos"?-c:c);if(h=="hide")c/=m*2;h!="hide"&&m--;if(h=="show"){var g={opacity:1};g[f]=(d=="pos"?"+=":"-=")+c;a.animate(g,i/2,b.options.easing);c/=2;m--}for(g=0;g<m;g++){var j={},k={};j[f]=(d=="pos"?"-=":"+=")+c;k[f]=(d=="pos"?"+=":"-=")+c;a.animate(j,i/2,b.options.easing).animate(k,i/2,b.options.easing);c=h=="hide"?c*2:c/2}if(h=="hide"){g={opacity:0};g[f]=(d=="pos"?"-=":"+=")+c;a.animate(g,i/2,b.options.easing,function(){a.hide();e.effects.restore(a,l);e.effects.removeWrapper(a);
+b.callback&&b.callback.apply(this,arguments)})}else{j={};k={};j[f]=(d=="pos"?"-=":"+=")+c;k[f]=(d=="pos"?"+=":"-=")+c;a.animate(j,i/2,b.options.easing).animate(k,i/2,b.options.easing,function(){e.effects.restore(a,l);e.effects.removeWrapper(a);b.callback&&b.callback.apply(this,arguments)})}a.queue("fx",function(){a.dequeue()});a.dequeue()})}})(jQuery);
+;/*
+ * jQuery UI Effects Clip 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Clip
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(b){b.effects.clip=function(e){return this.queue(function(){var a=b(this),i=["position","top","left","height","width"],f=b.effects.setMode(a,e.options.mode||"hide"),c=e.options.direction||"vertical";b.effects.save(a,i);a.show();var d=b.effects.createWrapper(a).css({overflow:"hidden"});d=a[0].tagName=="IMG"?d:a;var g={size:c=="vertical"?"height":"width",position:c=="vertical"?"top":"left"};c=c=="vertical"?d.height():d.width();if(f=="show"){d.css(g.size,0);d.css(g.position,c/2)}var h={};h[g.size]=
+f=="show"?c:0;h[g.position]=f=="show"?0:c/2;d.animate(h,{queue:false,duration:e.duration,easing:e.options.easing,complete:function(){f=="hide"&&a.hide();b.effects.restore(a,i);b.effects.removeWrapper(a);e.callback&&e.callback.apply(a[0],arguments);a.dequeue()}})})}})(jQuery);
+;/*
+ * jQuery UI Effects Drop 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Drop
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(c){c.effects.drop=function(d){return this.queue(function(){var a=c(this),h=["position","top","left","opacity"],e=c.effects.setMode(a,d.options.mode||"hide"),b=d.options.direction||"left";c.effects.save(a,h);a.show();c.effects.createWrapper(a);var f=b=="up"||b=="down"?"top":"left";b=b=="up"||b=="left"?"pos":"neg";var g=d.options.distance||(f=="top"?a.outerHeight({margin:true})/2:a.outerWidth({margin:true})/2);if(e=="show")a.css("opacity",0).css(f,b=="pos"?-g:g);var i={opacity:e=="show"?1:
+0};i[f]=(e=="show"?b=="pos"?"+=":"-=":b=="pos"?"-=":"+=")+g;a.animate(i,{queue:false,duration:d.duration,easing:d.options.easing,complete:function(){e=="hide"&&a.hide();c.effects.restore(a,h);c.effects.removeWrapper(a);d.callback&&d.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
+;/*
+ * jQuery UI Effects Explode 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Explode
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(j){j.effects.explode=function(a){return this.queue(function(){var c=a.options.pieces?Math.round(Math.sqrt(a.options.pieces)):3,d=a.options.pieces?Math.round(Math.sqrt(a.options.pieces)):3;a.options.mode=a.options.mode=="toggle"?j(this).is(":visible")?"hide":"show":a.options.mode;var b=j(this).show().css("visibility","hidden"),g=b.offset();g.top-=parseInt(b.css("marginTop"),10)||0;g.left-=parseInt(b.css("marginLeft"),10)||0;for(var h=b.outerWidth(true),i=b.outerHeight(true),e=0;e<c;e++)for(var f=
+0;f<d;f++)b.clone().appendTo("body").wrap("<div></div>").css({position:"absolute",visibility:"visible",left:-f*(h/d),top:-e*(i/c)}).parent().addClass("ui-effects-explode").css({position:"absolute",overflow:"hidden",width:h/d,height:i/c,left:g.left+f*(h/d)+(a.options.mode=="show"?(f-Math.floor(d/2))*(h/d):0),top:g.top+e*(i/c)+(a.options.mode=="show"?(e-Math.floor(c/2))*(i/c):0),opacity:a.options.mode=="show"?0:1}).animate({left:g.left+f*(h/d)+(a.options.mode=="show"?0:(f-Math.floor(d/2))*(h/d)),top:g.top+
+e*(i/c)+(a.options.mode=="show"?0:(e-Math.floor(c/2))*(i/c)),opacity:a.options.mode=="show"?1:0},a.duration||500);setTimeout(function(){a.options.mode=="show"?b.css({visibility:"visible"}):b.css({visibility:"visible"}).hide();a.callback&&a.callback.apply(b[0]);b.dequeue();j("div.ui-effects-explode").remove()},a.duration||500)})}})(jQuery);
+;/*
+ * jQuery UI Effects Fold 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Fold
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(c){c.effects.fold=function(a){return this.queue(function(){var b=c(this),j=["position","top","left"],d=c.effects.setMode(b,a.options.mode||"hide"),g=a.options.size||15,h=!!a.options.horizFirst,k=a.duration?a.duration/2:c.fx.speeds._default/2;c.effects.save(b,j);b.show();var e=c.effects.createWrapper(b).css({overflow:"hidden"}),f=d=="show"!=h,l=f?["width","height"]:["height","width"];f=f?[e.width(),e.height()]:[e.height(),e.width()];var i=/([0-9]+)%/.exec(g);if(i)g=parseInt(i[1],10)/100*
+f[d=="hide"?0:1];if(d=="show")e.css(h?{height:0,width:g}:{height:g,width:0});h={};i={};h[l[0]]=d=="show"?f[0]:g;i[l[1]]=d=="show"?f[1]:0;e.animate(h,k,a.options.easing).animate(i,k,a.options.easing,function(){d=="hide"&&b.hide();c.effects.restore(b,j);c.effects.removeWrapper(b);a.callback&&a.callback.apply(b[0],arguments);b.dequeue()})})}})(jQuery);
+;/*
+ * jQuery UI Effects Highlight 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Highlight
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(b){b.effects.highlight=function(c){return this.queue(function(){var a=b(this),e=["backgroundImage","backgroundColor","opacity"],d=b.effects.setMode(a,c.options.mode||"show"),f={backgroundColor:a.css("backgroundColor")};if(d=="hide")f.opacity=0;b.effects.save(a,e);a.show().css({backgroundImage:"none",backgroundColor:c.options.color||"#ffff99"}).animate(f,{queue:false,duration:c.duration,easing:c.options.easing,complete:function(){d=="hide"&&a.hide();b.effects.restore(a,e);d=="show"&&!b.support.opacity&&
+this.style.removeAttribute("filter");c.callback&&c.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
+;/*
+ * jQuery UI Effects Pulsate 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Pulsate
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(d){d.effects.pulsate=function(a){return this.queue(function(){var b=d(this),c=d.effects.setMode(b,a.options.mode||"show");times=(a.options.times||5)*2-1;duration=a.duration?a.duration/2:d.fx.speeds._default/2;isVisible=b.is(":visible");animateTo=0;if(!isVisible){b.css("opacity",0).show();animateTo=1}if(c=="hide"&&isVisible||c=="show"&&!isVisible)times--;for(c=0;c<times;c++){b.animate({opacity:animateTo},duration,a.options.easing);animateTo=(animateTo+1)%2}b.animate({opacity:animateTo},duration,
+a.options.easing,function(){animateTo==0&&b.hide();a.callback&&a.callback.apply(this,arguments)});b.queue("fx",function(){b.dequeue()}).dequeue()})}})(jQuery);
+;/*
+ * jQuery UI Effects Scale 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Scale
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(c){c.effects.puff=function(b){return this.queue(function(){var a=c(this),e=c.effects.setMode(a,b.options.mode||"hide"),g=parseInt(b.options.percent,10)||150,h=g/100,i={height:a.height(),width:a.width()};c.extend(b.options,{fade:true,mode:e,percent:e=="hide"?g:100,from:e=="hide"?i:{height:i.height*h,width:i.width*h}});a.effect("scale",b.options,b.duration,b.callback);a.dequeue()})};c.effects.scale=function(b){return this.queue(function(){var a=c(this),e=c.extend(true,{},b.options),g=c.effects.setMode(a,
+b.options.mode||"effect"),h=parseInt(b.options.percent,10)||(parseInt(b.options.percent,10)==0?0:g=="hide"?0:100),i=b.options.direction||"both",f=b.options.origin;if(g!="effect"){e.origin=f||["middle","center"];e.restore=true}f={height:a.height(),width:a.width()};a.from=b.options.from||(g=="show"?{height:0,width:0}:f);h={y:i!="horizontal"?h/100:1,x:i!="vertical"?h/100:1};a.to={height:f.height*h.y,width:f.width*h.x};if(b.options.fade){if(g=="show"){a.from.opacity=0;a.to.opacity=1}if(g=="hide"){a.from.opacity=
+1;a.to.opacity=0}}e.from=a.from;e.to=a.to;e.mode=g;a.effect("size",e,b.duration,b.callback);a.dequeue()})};c.effects.size=function(b){return this.queue(function(){var a=c(this),e=["position","top","left","width","height","overflow","opacity"],g=["position","top","left","overflow","opacity"],h=["width","height","overflow"],i=["fontSize"],f=["borderTopWidth","borderBottomWidth","paddingTop","paddingBottom"],k=["borderLeftWidth","borderRightWidth","paddingLeft","paddingRight"],p=c.effects.setMode(a,
+b.options.mode||"effect"),n=b.options.restore||false,m=b.options.scale||"both",l=b.options.origin,j={height:a.height(),width:a.width()};a.from=b.options.from||j;a.to=b.options.to||j;if(l){l=c.effects.getBaseline(l,j);a.from.top=(j.height-a.from.height)*l.y;a.from.left=(j.width-a.from.width)*l.x;a.to.top=(j.height-a.to.height)*l.y;a.to.left=(j.width-a.to.width)*l.x}var d={from:{y:a.from.height/j.height,x:a.from.width/j.width},to:{y:a.to.height/j.height,x:a.to.width/j.width}};if(m=="box"||m=="both"){if(d.from.y!=
+d.to.y){e=e.concat(f);a.from=c.effects.setTransition(a,f,d.from.y,a.from);a.to=c.effects.setTransition(a,f,d.to.y,a.to)}if(d.from.x!=d.to.x){e=e.concat(k);a.from=c.effects.setTransition(a,k,d.from.x,a.from);a.to=c.effects.setTransition(a,k,d.to.x,a.to)}}if(m=="content"||m=="both")if(d.from.y!=d.to.y){e=e.concat(i);a.from=c.effects.setTransition(a,i,d.from.y,a.from);a.to=c.effects.setTransition(a,i,d.to.y,a.to)}c.effects.save(a,n?e:g);a.show();c.effects.createWrapper(a);a.css("overflow","hidden").css(a.from);
+if(m=="content"||m=="both"){f=f.concat(["marginTop","marginBottom"]).concat(i);k=k.concat(["marginLeft","marginRight"]);h=e.concat(f).concat(k);a.find("*[width]").each(function(){child=c(this);n&&c.effects.save(child,h);var o={height:child.height(),width:child.width()};child.from={height:o.height*d.from.y,width:o.width*d.from.x};child.to={height:o.height*d.to.y,width:o.width*d.to.x};if(d.from.y!=d.to.y){child.from=c.effects.setTransition(child,f,d.from.y,child.from);child.to=c.effects.setTransition(child,
+f,d.to.y,child.to)}if(d.from.x!=d.to.x){child.from=c.effects.setTransition(child,k,d.from.x,child.from);child.to=c.effects.setTransition(child,k,d.to.x,child.to)}child.css(child.from);child.animate(child.to,b.duration,b.options.easing,function(){n&&c.effects.restore(child,h)})})}a.animate(a.to,{queue:false,duration:b.duration,easing:b.options.easing,complete:function(){a.to.opacity===0&&a.css("opacity",a.from.opacity);p=="hide"&&a.hide();c.effects.restore(a,n?e:g);c.effects.removeWrapper(a);b.callback&&
+b.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
+;/*
+ * jQuery UI Effects Shake 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Shake
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(d){d.effects.shake=function(a){return this.queue(function(){var b=d(this),j=["position","top","left"];d.effects.setMode(b,a.options.mode||"effect");var c=a.options.direction||"left",e=a.options.distance||20,l=a.options.times||3,f=a.duration||a.options.duration||140;d.effects.save(b,j);b.show();d.effects.createWrapper(b);var g=c=="up"||c=="down"?"top":"left",h=c=="up"||c=="left"?"pos":"neg";c={};var i={},k={};c[g]=(h=="pos"?"-=":"+=")+e;i[g]=(h=="pos"?"+=":"-=")+e*2;k[g]=(h=="pos"?"-=":"+=")+
+e*2;b.animate(c,f,a.options.easing);for(e=1;e<l;e++)b.animate(i,f,a.options.easing).animate(k,f,a.options.easing);b.animate(i,f,a.options.easing).animate(c,f/2,a.options.easing,function(){d.effects.restore(b,j);d.effects.removeWrapper(b);a.callback&&a.callback.apply(this,arguments)});b.queue("fx",function(){b.dequeue()});b.dequeue()})}})(jQuery);
+;/*
+ * jQuery UI Effects Slide 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Slide
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(c){c.effects.slide=function(d){return this.queue(function(){var a=c(this),h=["position","top","left"],e=c.effects.setMode(a,d.options.mode||"show"),b=d.options.direction||"left";c.effects.save(a,h);a.show();c.effects.createWrapper(a).css({overflow:"hidden"});var f=b=="up"||b=="down"?"top":"left";b=b=="up"||b=="left"?"pos":"neg";var g=d.options.distance||(f=="top"?a.outerHeight({margin:true}):a.outerWidth({margin:true}));if(e=="show")a.css(f,b=="pos"?-g:g);var i={};i[f]=(e=="show"?b=="pos"?
+"+=":"-=":b=="pos"?"-=":"+=")+g;a.animate(i,{queue:false,duration:d.duration,easing:d.options.easing,complete:function(){e=="hide"&&a.hide();c.effects.restore(a,h);c.effects.removeWrapper(a);d.callback&&d.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
+;/*
+ * jQuery UI Effects Transfer 1.8.2
+ *
+ * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * http://docs.jquery.com/UI/Effects/Transfer
+ *
+ * Depends:
+ *     jquery.effects.core.js
+ */
+(function(e){e.effects.transfer=function(a){return this.queue(function(){var b=e(this),c=e(a.options.to),d=c.offset();c={top:d.top,left:d.left,height:c.innerHeight(),width:c.innerWidth()};d=b.offset();var f=e('<div class="ui-effects-transfer"></div>').appendTo(document.body).addClass(a.options.className).css({top:d.top,left:d.left,height:b.innerHeight(),width:b.innerWidth(),position:"absolute"}).animate(c,a.duration,a.options.easing,function(){f.remove();a.callback&&a.callback.apply(b[0],arguments);
+b.dequeue()})})}})(jQuery);
+;
\ No newline at end of file
diff --git a/web/js/jquery.MultiFile.pack.js b/web/js/jquery.MultiFile.pack.js
new file mode 100755 (executable)
index 0000000..2ef968a
--- /dev/null
@@ -0,0 +1,11 @@
+/*\r
+ ### jQuery Multiple File Upload Plugin v1.46 - 2009-05-12 ###\r
+ * Home: http://www.fyneworks.com/jquery/multiple-file-upload/\r
+ * Code: http://code.google.com/p/jquery-multifile-plugin/\r
+ *\r
+ * Dual licensed under the MIT and GPL licenses:\r
+ *   http://www.opensource.org/licenses/mit-license.php\r
+ *   http://www.gnu.org/licenses/gpl.html\r
+ ###\r
+*/\r
+eval(function(p,a,c,k,e,r){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--)r[e(c)]=k[c]||e(c);k=[function(e){return r[e]}];e=function(){return'\\w+'};c=1};while(c--)if(k[c])p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c]);return p}(';3(U.1u)(6($){$.7.2=6(h){3(5.V==0)8 5;3(T S[0]==\'19\'){3(5.V>1){m i=S;8 5.M(6(){$.7.2.13($(5),i)})};$.7.2[S[0]].13(5,$.1N(S).27(1)||[]);8 5};m h=$.N({},$.7.2.F,h||{});$(\'2d\').1B(\'2-R\').Q(\'2-R\').1n($.7.2.Z);3($.7.2.F.15){$.7.2.1M($.7.2.F.15);$.7.2.F.15=10};5.1B(\'.2-1e\').Q(\'2-1e\').M(6(){U.2=(U.2||0)+1;m e=U.2;m g={e:5,E:$(5),L:$(5).L()};3(T h==\'21\')h={l:h};m o=$.N({},$.7.2.F,h||{},($.1m?g.E.1m():($.1S?g.E.17():10))||{},{});3(!(o.l>0)){o.l=g.E.D(\'28\');3(!(o.l>0)){o.l=(u(g.e.1D.B(/\\b(l|23)\\-([0-9]+)\\b/q)||[\'\']).B(/[0-9]+/q)||[\'\'])[0];3(!(o.l>0))o.l=-1;2b o.l=u(o.l).B(/[0-9]+/q)[0]}};o.l=18 2f(o.l);o.j=o.j||g.E.D(\'j\')||\'\';3(!o.j){o.j=(g.e.1D.B(/\\b(j\\-[\\w\\|]+)\\b/q))||\'\';o.j=18 u(o.j).t(/^(j|1d)\\-/i,\'\')};$.N(g,o||{});g.A=$.N({},$.7.2.F.A,g.A);$.N(g,{n:0,J:[],2c:[],1c:g.e.I||\'2\'+u(e),1i:6(z){8 g.1c+(z>0?\'1Z\'+u(z):\'\')},G:6(a,b){m c=g[a],k=$(b).D(\'k\');3(c){m d=c(b,k,g);3(d!=10)8 d}8 1a}});3(u(g.j).V>1){g.j=g.j.t(/\\W+/g,\'|\').t(/^\\W|\\W$/g,\'\');g.1k=18 2t(\'\\\\.(\'+(g.j?g.j:\'\')+\')$\',\'q\')};g.O=g.1c+\'1P\';g.E.1l(\'<P X="2-1l" I="\'+g.O+\'"></P>\');g.1q=$(\'#\'+g.O+\'\');g.e.H=g.e.H||\'p\'+e+\'[]\';3(!g.K){g.1q.1g(\'<P X="2-K" I="\'+g.O+\'1F"></P>\');g.K=$(\'#\'+g.O+\'1F\')};g.K=$(g.K);g.16=6(c,d){g.n++;c.2=g;3(d>0)c.I=c.H=\'\';3(d>0)c.I=g.1i(d);c.H=u(g.1j.t(/\\$H/q,$(g.L).D(\'H\')).t(/\\$I/q,$(g.L).D(\'I\')).t(/\\$g/q,e).t(/\\$i/q,d));3((g.l>0)&&((g.n-1)>(g.l)))c.14=1a;g.Y=g.J[d]=c;c=$(c);c.1b(\'\').D(\'k\',\'\')[0].k=\'\';c.Q(\'2-1e\');c.1V(6(){$(5).1X();3(!g.G(\'1Y\',5,g))8 y;m a=\'\',v=u(5.k||\'\');3(g.j&&v&&!v.B(g.1k))a=g.A.1o.t(\'$1d\',u(v.B(/\\.\\w{1,4}$/q)));1p(m f 2a g.J)3(g.J[f]&&g.J[f]!=5)3(g.J[f].k==v)a=g.A.1r.t(\'$p\',v.B(/[^\\/\\\\]+$/q));m b=$(g.L).L();b.Q(\'2\');3(a!=\'\'){g.1s(a);g.n--;g.16(b[0],d);c.1t().2e(b);c.C();8 y};$(5).1v({1w:\'1O\',1x:\'-1Q\'});c.1R(b);g.1y(5,d);g.16(b[0],d+1);3(!g.G(\'1T\',5,g))8 y});$(c).17(\'2\',g)};g.1y=6(c,d){3(!g.G(\'1U\',c,g))8 y;m r=$(\'<P X="2-1W"></P>\'),v=u(c.k||\'\'),a=$(\'<1z X="2-1A" 1A="\'+g.A.12.t(\'$p\',v)+\'">\'+g.A.p.t(\'$p\',v.B(/[^\\/\\\\]+$/q)[0])+\'</1z>\'),b=$(\'<a X="2-C" 2y="#\'+g.O+\'">\'+g.A.C+\'</a>\');g.K.1g(r.1g(b,\' \',a));b.1C(6(){3(!g.G(\'22\',c,g))8 y;g.n--;g.Y.14=y;g.J[d]=10;$(c).C();$(5).1t().C();$(g.Y).1v({1w:\'\',1x:\'\'});$(g.Y).11().1b(\'\').D(\'k\',\'\')[0].k=\'\';3(!g.G(\'24\',c,g))8 y;8 y});3(!g.G(\'25\',c,g))8 y};3(!g.2)g.16(g.e,0);g.n++;g.E.17(\'2\',g)})};$.N($.7.2,{11:6(){m a=$(5).17(\'2\');3(a)a.K.26(\'a.2-C\').1C();8 $(5)},Z:6(a){a=(T(a)==\'19\'?a:\'\')||\'1E\';m o=[];$(\'1h:p.2\').M(6(){3($(5).1b()==\'\')o[o.V]=5});8 $(o).M(6(){5.14=1a}).Q(a)},1f:6(a){a=(T(a)==\'19\'?a:\'\')||\'1E\';8 $(\'1h:p.\'+a).29(a).M(6(){5.14=y})},R:{},1M:6(b,c,d){m e,k;d=d||[];3(d.1G.1H().1I("1J")<0)d=[d];3(T(b)==\'6\'){$.7.2.Z();k=b.13(c||U,d);1K(6(){$.7.2.1f()},1L);8 k};3(b.1G.1H().1I("1J")<0)b=[b];1p(m i=0;i<b.V;i++){e=b[i]+\'\';3(e)(6(a){$.7.2.R[a]=$.7[a]||6(){};$.7[a]=6(){$.7.2.Z();k=$.7.2.R[a].13(5,S);1K(6(){$.7.2.1f()},1L);8 k}})(e)}}});$.7.2.F={j:\'\',l:-1,1j:\'$H\',A:{C:\'x\',1o:\'2g 2h 2i a $1d p.\\2j 2k...\',p:\'$p\',12:\'2l 12: $p\',1r:\'2m p 2n 2o 2p 12:\\n$p\'},15:[\'1n\',\'2q\',\'2r\',\'2s\'],1s:6(s){2u(s)}};$.7.11=6(){8 5.M(6(){2v{5.11()}2w(e){}})};$(6(){$("1h[2x=p].20").2()})})(1u);',62,159,'||MultiFile|if||this|function|fn|return|||||||||||accept|value|max|var|||file|gi|||replace|String||||false||STRING|match|remove|attr||options|trigger|name|id|slaves|list|clone|each|extend|wrapID|div|addClass|intercepted|arguments|typeof|window|length||class|current|disableEmpty|null|reset|selected|apply|disabled|autoIntercept|addSlave|data|new|string|true|val|instanceKey|ext|applied|reEnableEmpty|append|input|generateID|namePattern|rxAccept|wrap|metadata|submit|denied|for|wrapper|duplicate|error|parent|jQuery|css|position|top|addToList|span|title|not|click|className|mfD|_list|constructor|toString|indexOf|Array|setTimeout|1000|intercept|makeArray|absolute|_wrap|3000px|after|meta|afterFileSelect|onFileAppend|change|label|blur|onFileSelect|_F|multi|number|onFileRemove|limit|afterFileRemove|afterFileAppend|find|slice|maxlength|removeClass|in|else|files|form|prepend|Number|You|cannot|select|nTry|again|File|This|has|already|been|ajaxSubmit|ajaxForm|validate|RegExp|alert|try|catch|type|href'.split('|'),0,{}))
\ No newline at end of file
diff --git a/web/js/jquery.asmselect.js b/web/js/jquery.asmselect.js
new file mode 100644 (file)
index 0000000..5dde56a
--- /dev/null
@@ -0,0 +1,407 @@
+/*
+ * Alternate Select Multiple (asmSelect) 1.0.4a beta - jQuery Plugin
+ * http://www.ryancramer.com/projects/asmselect/
+ * 
+ * Copyright (c) 2009 by Ryan Cramer - http://www.ryancramer.com
+ * 
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ */
+
+(function($) {
+
+       $.fn.asmSelect = function(customOptions) {
+
+               var options = {
+
+                       listType: 'ol',                                         // Ordered list 'ol', or unordered list 'ul'
+                       sortable: false,                                        // Should the list be sortable?
+                       highlight: false,                                       // Use the highlight feature? 
+                       animate: false,                                         // Animate the the adding/removing of items in the list?
+                       addItemTarget: 'bottom',                                // Where to place new selected items in list: top or bottom
+                       hideWhenAdded: false,                                   // Hide the option when added to the list? works only in FF
+                       debugMode: false,                                       // Debug mode keeps original select visible 
+
+                       removeLabel: 'remove',                                  // Text used in the "remove" link
+                       highlightAddedLabel: 'Added: ',                         // Text that precedes highlight of added item
+                       highlightRemovedLabel: 'Removed: ',                     // Text that precedes highlight of removed item
+
+                       containerClass: 'asmContainer',                         // Class for container that wraps this widget
+                       selectClass: 'asmSelect',                               // Class for the newly created <select>
+                       optionDisabledClass: 'asmOptionDisabled',               // Class for items that are already selected / disabled
+                       listClass: 'asmList',                                   // Class for the list ($ol)
+                       listSortableClass: 'asmListSortable',                   // Another class given to the list when it is sortable
+                       listItemClass: 'asmListItem',                           // Class for the <li> list items
+                       listItemLabelClass: 'asmListItemLabel',                 // Class for the label text that appears in list items
+                       removeClass: 'asmListItemRemove',                       // Class given to the "remove" link
+                       highlightClass: 'asmHighlight'                          // Class given to the highlight <span>
+
+                       };
+
+               $.extend(options, customOptions); 
+
+               return this.each(function(index) {
+
+                       var $original = $(this);                                // the original select multiple
+                       var $container;                                         // a container that is wrapped around our widget
+                       var $select;                                            // the new select we have created
+                       var $ol;                                                // the list that we are manipulating
+                       var buildingSelect = false;                             // is the new select being constructed right now?
+                       var ieClick = false;                                    // in IE, has a click event occurred? ignore if not
+                       var ignoreOriginalChangeEvent = false;                  // originalChangeEvent bypassed when this is true
+
+                       function init() {
+
+                               // initialize the alternate select multiple
+
+                               // this loop ensures uniqueness, in case of existing asmSelects placed by ajax (1.0.3)
+                               while($("#" + options.containerClass + index).size() > 0) index++; 
+
+                               $select = $("<select></select>")
+                                       .addClass(options.selectClass)
+                                       .attr('name', options.selectClass + index)
+                                       .attr('id', options.selectClass + index); 
+
+                               $selectRemoved = $("<select></select>"); 
+
+                               $ol = $("<" + options.listType + "></" + options.listType + ">")
+                                       .addClass(options.listClass)
+                                       .attr('id', options.listClass + index); 
+
+                               $container = $("<div></div>")
+                                       .addClass(options.containerClass) 
+                                       .attr('id', options.containerClass + index); 
+
+                               buildSelect();
+
+                               $select.change(selectChangeEvent)
+                                       .click(selectClickEvent); 
+
+                               $original.change(originalChangeEvent)
+                                       .wrap($container).before($select).before($ol);
+
+                               if(options.sortable) makeSortable();
+
+                               if($.browser.msie && $.browser.version < 8) $ol.css('display', 'inline-block'); // Thanks Matthew Hutton
+                       }
+
+                       function makeSortable() {
+
+                               // make any items in the selected list sortable
+                               // requires jQuery UI sortables, draggables, droppables
+
+                               $ol.sortable({
+                                       items: 'li.' + options.listItemClass,
+                                       handle: '.' + options.listItemLabelClass,
+                                       axis: 'y',
+                                       update: function(e, data) {
+
+                                               var updatedOptionId;
+
+                                               $(this).children("li").each(function(n) {
+
+                                                       $option = $('#' + $(this).attr('rel')); 
+
+                                                       if($(this).is(".ui-sortable-helper")) {
+                                                               updatedOptionId = $option.attr('id'); 
+                                                               return;
+                                                       }
+
+                                                       $original.append($option); 
+                                               }); 
+
+                                               if(updatedOptionId) triggerOriginalChange(updatedOptionId, 'sort'); 
+                                       }
+
+                               }).addClass(options.listSortableClass); 
+                       }
+
+                       function selectChangeEvent(e) {
+                               
+                               // an item has been selected on the regular select we created
+                               // check to make sure it's not an IE screwup, and add it to the list
+
+                               if($.browser.msie && $.browser.version < 7 && !ieClick) return;
+                               var id = $(this).children("option:selected").slice(0,1).attr('rel'); 
+                               addListItem(id);        
+                               ieClick = false; 
+                               triggerOriginalChange(id, 'add'); // for use by user-defined callbacks
+                       }
+
+                       function selectClickEvent() {
+
+                               // IE6 lets you scroll around in a select without it being pulled down
+                               // making sure a click preceded the change() event reduces the chance
+                               // if unintended items being added. there may be a better solution?
+
+                               ieClick = true; 
+                       }
+
+                       function originalChangeEvent(e) {
+
+                               // select or option change event manually triggered
+                               // on the original <select multiple>, so rebuild ours
+
+                               if(ignoreOriginalChangeEvent) {
+                                       ignoreOriginalChangeEvent = false; 
+                                       return; 
+                               }
+
+                               $select.empty();
+                               $ol.empty();
+                               buildSelect();
+
+                               // opera has an issue where it needs a force redraw, otherwise
+                               // the items won't appear until something else forces a redraw
+                               if($.browser.opera) $ol.hide().fadeIn("fast");
+                       }
+
+                       function buildSelect() {
+
+                               // build or rebuild the new select that the user
+                               // will select items from
+
+                               buildingSelect = true; 
+
+                               // add a first option to be the home option / default selectLabel
+                               $select.prepend("<option>" + $original.attr('title') + "</option>"); 
+
+                               $original.children("option").each(function(n) {
+
+                                       var $t = $(this); 
+                                       var id; 
+
+                                       if(!$t.attr('id')) $t.attr('id', 'asm' + index + 'option' + n); 
+                                       id = $t.attr('id'); 
+
+                                       if($t.is(":selected")) {
+                                               addListItem(id); 
+                                               addSelectOption(id, true);                                              
+                                       } else {
+                                               addSelectOption(id); 
+                                       }
+                               });
+
+                               if(!options.debugMode) $original.hide(); // IE6 requires this on every buildSelect()
+                               selectFirstItem();
+                               buildingSelect = false; 
+                       }
+
+                       function addSelectOption(optionId, disabled) {
+
+                               // add an <option> to the <select>
+                               // used only by buildSelect()
+
+                               if(disabled == undefined) var disabled = false; 
+
+                               var $O = $('#' + optionId); 
+                               var $option = $("<option>" + $O.text() + "</option>")
+                                       .val($O.val())
+                                       .attr('rel', optionId);
+
+                               if(disabled) disableSelectOption($option); 
+
+                               $select.append($option); 
+                       }
+
+                       function selectFirstItem() {
+
+                               // select the firm item from the regular select that we created
+
+                               $select.children(":eq(0)").attr("selected", true); 
+                       }
+
+                       function disableSelectOption($option) {
+
+                               // make an option disabled, indicating that it's already been selected
+                               // because safari is the only browser that makes disabled items look 'disabled'
+                               // we apply a class that reproduces the disabled look in other browsers
+
+                               $option.addClass(options.optionDisabledClass)
+                                       .attr("selected", false)
+                                       .attr("disabled", true);
+
+                               if(options.hideWhenAdded) $option.hide();
+                               if($.browser.msie) $select.hide().show(); // this forces IE to update display
+                       }
+
+                       function enableSelectOption($option) {
+
+                               // given an already disabled select option, enable it
+
+                               $option.removeClass(options.optionDisabledClass)
+                                       .attr("disabled", false);
+
+                               if(options.hideWhenAdded) $option.show();
+                               if($.browser.msie) $select.hide().show(); // this forces IE to update display
+                       }
+
+                       function addListItem(optionId) {
+
+                               // add a new item to the html list
+
+                               var $O = $('#' + optionId); 
+
+                               if(!$O) return; // this is the first item, selectLabel
+
+                               var $removeLink = $("<a></a>")
+                                       .attr("href", "#")
+                                       .addClass(options.removeClass)
+                                       .prepend(options.removeLabel)
+                                       .click(function() { 
+                                               dropListItem($(this).parent('li').attr('rel')); 
+                                               return false; 
+                                       }); 
+
+                               var $itemLabel = $("<span></span>")
+                                       .addClass(options.listItemLabelClass)
+                                       .html($O.html()); 
+
+                               var $item = $("<li></li>")
+                                       .attr('rel', optionId)
+                                       .addClass(options.listItemClass)
+                                       .append($itemLabel)
+                                       .append($removeLink)
+                                       .hide();
+
+                               if(!buildingSelect) {
+                                       if($O.is(":selected")) return; // already have it
+                                       $O.attr('selected', true); 
+                               }
+
+                               if(options.addItemTarget == 'top' && !buildingSelect) {
+                                       $ol.prepend($item); 
+                                       if(options.sortable) $original.prepend($O); 
+                               } else {
+                                       $ol.append($item); 
+                                       if(options.sortable) $original.append($O); 
+                               }
+
+                               addListItemShow($item); 
+
+                               disableSelectOption($("[rel=" + optionId + "]", $select));
+
+                               if(!buildingSelect) {
+                                       setHighlight($item, options.highlightAddedLabel); 
+                                       selectFirstItem();
+                                       if(options.sortable) $ol.sortable("refresh");   
+                               }
+
+                       }
+
+                       function addListItemShow($item) {
+
+                               // reveal the currently hidden item with optional animation
+                               // used only by addListItem()
+
+                               if(options.animate && !buildingSelect) {
+                                       $item.animate({
+                                               opacity: "show",
+                                               height: "show"
+                                       }, 100, "swing", function() { 
+                                               $item.animate({
+                                                       height: "+=2px"
+                                               }, 50, "swing", function() {
+                                                       $item.animate({
+                                                               height: "-=2px"
+                                                       }, 25, "swing"); 
+                                               }); 
+                                       }); 
+                               } else {
+                                       $item.show();
+                               }
+                       }
+
+                       function dropListItem(optionId, highlightItem) {
+
+                               // remove an item from the html list
+
+                               if(highlightItem == undefined) var highlightItem = true; 
+                               var $O = $('#' + optionId); 
+
+                               $O.attr('selected', false); 
+                               $item = $ol.children("li[rel=" + optionId + "]");
+
+                               dropListItemHide($item); 
+                               enableSelectOption($("[rel=" + optionId + "]", options.removeWhenAdded ? $selectRemoved : $select));
+
+                               if(highlightItem) setHighlight($item, options.highlightRemovedLabel); 
+
+                               triggerOriginalChange(optionId, 'drop'); 
+                               
+                       }
+
+                       function dropListItemHide($item) {
+
+                               // remove the currently visible item with optional animation
+                               // used only by dropListItem()
+
+                               if(options.animate && !buildingSelect) {
+
+                                       $prevItem = $item.prev("li");
+
+                                       $item.animate({
+                                               opacity: "hide",
+                                               height: "hide"
+                                       }, 100, "linear", function() {
+                                               $prevItem.animate({
+                                                       height: "-=2px"
+                                               }, 50, "swing", function() {
+                                                       $prevItem.animate({
+                                                               height: "+=2px"
+                                                       }, 100, "swing"); 
+                                               }); 
+                                               $item.remove(); 
+                                       }); 
+                                       
+                               } else {
+                                       $item.remove(); 
+                               }
+                       }
+
+                       function setHighlight($item, label) {
+
+                               // set the contents of the highlight area that appears
+                               // directly after the <select> single
+                               // fade it in quickly, then fade it out
+
+                               if(!options.highlight) return; 
+
+                               $select.next("#" + options.highlightClass + index).remove();
+
+                               var $highlight = $("<span></span>")
+                                       .hide()
+                                       .addClass(options.highlightClass)
+                                       .attr('id', options.highlightClass + index)
+                                       .html(label + $item.children("." + options.listItemLabelClass).slice(0,1).text()); 
+                                       
+                               $select.after($highlight); 
+
+                               $highlight.fadeIn("fast", function() {
+                                       setTimeout(function() { $highlight.fadeOut("slow"); }, 50); 
+                               }); 
+                       }
+
+                       function triggerOriginalChange(optionId, type) {
+
+                               // trigger a change event on the original select multiple
+                               // so that other scripts can pick them up
+
+                               ignoreOriginalChangeEvent = true; 
+                               $option = $("#" + optionId); 
+
+                               $original.trigger('change', [{
+                                       'option': $option,
+                                       'value': $option.val(),
+                                       'id': optionId,
+                                       'item': $ol.children("[rel=" + optionId + "]"),
+                                       'type': type
+                               }]); 
+                       }
+
+                       init();
+               });
+       };
+
+})(jQuery); 
diff --git a/web/js/jquery.cookie.js b/web/js/jquery.cookie.js
new file mode 100644 (file)
index 0000000..8e8e1d9
--- /dev/null
@@ -0,0 +1,92 @@
+/**
+ * Cookie plugin
+ *
+ * Copyright (c) 2006 Klaus Hartl (stilbuero.de)
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ */
+
+/**
+ * Create a cookie with the given name and value and other optional parameters.
+ *
+ * @example $.cookie('the_cookie', 'the_value');
+ * @desc Set the value of a cookie.
+ * @example $.cookie('the_cookie', 'the_value', {expires: 7, path: '/', domain: 'jquery.com', secure: true});
+ * @desc Create a cookie with all available options.
+ * @example $.cookie('the_cookie', 'the_value');
+ * @desc Create a session cookie.
+ * @example $.cookie('the_cookie', null);
+ * @desc Delete a cookie by passing null as value.
+ *
+ * @param String name The name of the cookie.
+ * @param String value The value of the cookie.
+ * @param Object options An object literal containing key/value pairs to provide optional cookie attributes.
+ * @option Number|Date expires Either an integer specifying the expiration date from now on in days or a Date object.
+ *                             If a negative value is specified (e.g. a date in the past), the cookie will be deleted.
+ *                             If set to null or omitted, the cookie will be a session cookie and will not be retained
+ *                             when the the browser exits.
+ * @option String path The value of the path atribute of the cookie (default: path of page that created the cookie).
+ * @option String domain The value of the domain attribute of the cookie (default: domain of page that created the cookie).
+ * @option Boolean secure If true, the secure attribute of the cookie will be set and the cookie transmission will
+ *                        require a secure protocol (like HTTPS).
+ * @type undefined
+ *
+ * @name $.cookie
+ * @cat Plugins/Cookie
+ * @author Klaus Hartl/klaus.hartl@stilbuero.de
+ */
+
+/**
+ * Get the value of a cookie with the given name.
+ *
+ * @example $.cookie('the_cookie');
+ * @desc Get the value of a cookie.
+ *
+ * @param String name The name of the cookie.
+ * @return The value of the cookie.
+ * @type String
+ *
+ * @name $.cookie
+ * @cat Plugins/Cookie
+ * @author Klaus Hartl/klaus.hartl@stilbuero.de
+ */
+jQuery.cookie = function(name, value, options) {
+    if (typeof value != 'undefined') { // name and value given, set cookie
+        options = options || {};
+        if (value === null) {
+            value = '';
+            options.expires = -1;
+        }
+        var expires = '';
+        if (options.expires && (typeof options.expires == 'number' || options.expires.toUTCString)) {
+            var date;
+            if (typeof options.expires == 'number') {
+                date = new Date();
+                date.setTime(date.getTime() + (options.expires * 24 * 60 * 60 * 1000));
+            } else {
+                date = options.expires;
+            }
+            expires = '; expires=' + date.toUTCString(); // use expires attribute, max-age is not supported by IE
+        }
+        var path = options.path ? '; path=' + options.path : '';
+        var domain = options.domain ? '; domain=' + options.domain : '';
+        var secure = options.secure ? '; secure' : '';
+        document.cookie = [name, '=', encodeURIComponent(value), expires, path, domain, secure].join('');
+    } else { // only name given, get cookie
+        var cookieValue = null;
+        if (document.cookie && document.cookie != '') {
+            var cookies = document.cookie.split(';');
+            for (var i = 0; i < cookies.length; i++) {
+                var cookie = jQuery.trim(cookies[i]);
+                // Does this cookie string begin with the name we want?
+                if (cookie.substring(0, name.length + 1) == (name + '=')) {
+                    cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+                    break;
+                }
+            }
+        }
+        return cookieValue;
+    }
+};
\ No newline at end of file
diff --git a/web/js/jquery.flot.pack.js b/web/js/jquery.flot.pack.js
new file mode 100644 (file)
index 0000000..6534a46
--- /dev/null
@@ -0,0 +1,2119 @@
+/* Javascript plotting library for jQuery, v. 0.6.
+ *
+ * Released under the MIT license by IOLA, December 2007.
+ *
+ */
+
+// first an inline dependency, jquery.colorhelpers.js, we inline it here
+// for convenience
+
+/* Plugin for jQuery for working with colors.
+ * 
+ * Version 1.0.
+ * 
+ * Inspiration from jQuery color animation plugin by John Resig.
+ *
+ * Released under the MIT license by Ole Laursen, October 2009.
+ *
+ * Examples:
+ *
+ *   $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString()
+ *   var c = $.color.extract($("#mydiv"), 'background-color');
+ *   console.log(c.r, c.g, c.b, c.a);
+ *   $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)"
+ *
+ * Note that .scale() and .add() work in-place instead of returning
+ * new objects.
+ */ 
+(function(){jQuery.color={};jQuery.color.make=function(E,D,B,C){var F={};F.r=E||0;F.g=D||0;F.b=B||0;F.a=C!=null?C:1;F.add=function(I,H){for(var G=0;G<I.length;++G){F[I.charAt(G)]+=H}return F.normalize()};F.scale=function(I,H){for(var G=0;G<I.length;++G){F[I.charAt(G)]*=H}return F.normalize()};F.toString=function(){if(F.a>=1){return"rgb("+[F.r,F.g,F.b].join(",")+")"}else{return"rgba("+[F.r,F.g,F.b,F.a].join(",")+")"}};F.normalize=function(){function G(I,J,H){return J<I?I:(J>H?H:J)}F.r=G(0,parseInt(F.r),255);F.g=G(0,parseInt(F.g),255);F.b=G(0,parseInt(F.b),255);F.a=G(0,F.a,1);return F};F.clone=function(){return jQuery.color.make(F.r,F.b,F.g,F.a)};return F.normalize()};jQuery.color.extract=function(C,B){var D;do{D=C.css(B).toLowerCase();if(D!=""&&D!="transparent"){break}C=C.parent()}while(!jQuery.nodeName(C.get(0),"body"));if(D=="rgba(0, 0, 0, 0)"){D="transparent"}return jQuery.color.parse(D)};jQuery.color.parse=function(E){var D,B=jQuery.color.make;if(D=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(E)){return B(parseInt(D[1],10),parseInt(D[2],10),parseInt(D[3],10))}if(D=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(E)){return B(parseInt(D[1],10),parseInt(D[2],10),parseInt(D[3],10),parseFloat(D[4]))}if(D=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(E)){return B(parseFloat(D[1])*2.55,parseFloat(D[2])*2.55,parseFloat(D[3])*2.55)}if(D=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(E)){return B(parseFloat(D[1])*2.55,parseFloat(D[2])*2.55,parseFloat(D[3])*2.55,parseFloat(D[4]))}if(D=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(E)){return B(parseInt(D[1],16),parseInt(D[2],16),parseInt(D[3],16))}if(D=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(E)){return B(parseInt(D[1]+D[1],16),parseInt(D[2]+D[2],16),parseInt(D[3]+D[3],16))}var C=jQuery.trim(E).toLowerCase();if(C=="transparent"){return B(255,255,255,0)}else{D=A[C];return B(D[0],D[1],D[2])}};var A={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})();
+
+// the actual Flot code
+(function($) {
+    function Plot(placeholder, data_, options_, plugins) {
+        // data is on the form:
+        //   [ series1, series2 ... ]
+        // where series is either just the data as [ [x1, y1], [x2, y2], ... ]
+        // or { data: [ [x1, y1], [x2, y2], ... ], label: "some label", ... }
+        
+        var series = [],
+            options = {
+                // the color theme used for graphs
+                colors: ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"],
+                legend: {
+                    show: true,
+                    noColumns: 1, // number of colums in legend table
+                    labelFormatter: null, // fn: string -> string
+                    labelBoxBorderColor: "#ccc", // border color for the little label boxes
+                    container: null, // container (as jQuery object) to put legend in, null means default on top of graph
+                    position: "ne", // position of default legend container within plot
+                    margin: 5, // distance from grid edge to default legend container within plot
+                    backgroundColor: null, // null means auto-detect
+                    backgroundOpacity: 0.85 // set to 0 to avoid background
+                },
+                xaxis: {
+                    mode: null, // null or "time"
+                    transform: null, // null or f: number -> number to transform axis
+                    inverseTransform: null, // if transform is set, this should be the inverse function
+                    min: null, // min. value to show, null means set automatically
+                    max: null, // max. value to show, null means set automatically
+                    autoscaleMargin: null, // margin in % to add if auto-setting min/max
+                    ticks: null, // either [1, 3] or [[1, "a"], 3] or (fn: axis info -> ticks) or app. number of ticks for auto-ticks
+                    tickFormatter: null, // fn: number -> string
+                    labelWidth: null, // size of tick labels in pixels
+                    labelHeight: null,
+                    
+                    // mode specific options
+                    tickDecimals: null, // no. of decimals, null means auto
+                    tickSize: null, // number or [number, "unit"]
+                    minTickSize: null, // number or [number, "unit"]
+                    monthNames: null, // list of names of months
+                    timeformat: null, // format string to use
+                    twelveHourClock: false // 12 or 24 time in time mode
+                },
+                yaxis: {
+                    autoscaleMargin: 0.02
+                },
+                x2axis: {
+                    autoscaleMargin: null
+                },
+                y2axis: {
+                    autoscaleMargin: 0.02
+                },
+                series: {
+                    points: {
+                        show: false,
+                        radius: 3,
+                        lineWidth: 2, // in pixels
+                        fill: true,
+                        fillColor: "#ffffff"
+                    },
+                    lines: {
+                        // we don't put in show: false so we can see
+                        // whether lines were actively disabled 
+                        lineWidth: 2, // in pixels
+                        fill: false,
+                        fillColor: null,
+                        steps: false
+                    },
+                    bars: {
+                        show: false,
+                        lineWidth: 2, // in pixels
+                        barWidth: 1, // in units of the x axis
+                        fill: true,
+                        fillColor: null,
+                        align: "left", // or "center" 
+                        horizontal: false // when horizontal, left is now top
+                    },
+                    shadowSize: 3
+                },
+                grid: {
+                    show: true,
+                    aboveData: false,
+                    color: "#545454", // primary color used for outline and labels
+                    backgroundColor: null, // null for transparent, else color
+                    tickColor: "rgba(0,0,0,0.15)", // color used for the ticks
+                    labelMargin: 5, // in pixels
+                    borderWidth: 2, // in pixels
+                    borderColor: null, // set if different from the grid color
+                    markings: null, // array of ranges or fn: axes -> array of ranges
+                    markingsColor: "#f4f4f4",
+                    markingsLineWidth: 2,
+                    // interactive stuff
+                    clickable: false,
+                    hoverable: false,
+                    autoHighlight: true, // highlight in case mouse is near
+                    mouseActiveRadius: 10 // how far the mouse can be away to activate an item
+                },
+                hooks: {}
+            },
+        canvas = null,      // the canvas for the plot itself
+        overlay = null,     // canvas for interactive stuff on top of plot
+        eventHolder = null, // jQuery object that events should be bound to
+        ctx = null, octx = null,
+        axes = { xaxis: {}, yaxis: {}, x2axis: {}, y2axis: {} },
+        plotOffset = { left: 0, right: 0, top: 0, bottom: 0},
+        canvasWidth = 0, canvasHeight = 0,
+        plotWidth = 0, plotHeight = 0,
+        hooks = {
+            processOptions: [],
+            processRawData: [],
+            processDatapoints: [],
+            draw: [],
+            bindEvents: [],
+            drawOverlay: []
+        },
+        plot = this;
+
+        // public functions
+        plot.setData = setData;
+        plot.setupGrid = setupGrid;
+        plot.draw = draw;
+        plot.getPlaceholder = function() { return placeholder; };
+        plot.getCanvas = function() { return canvas; };
+        plot.getPlotOffset = function() { return plotOffset; };
+        plot.width = function () { return plotWidth; };
+        plot.height = function () { return plotHeight; };
+        plot.offset = function () {
+            var o = eventHolder.offset();
+            o.left += plotOffset.left;
+            o.top += plotOffset.top;
+            return o;
+        };
+        plot.getData = function() { return series; };
+        plot.getAxes = function() { return axes; };
+        plot.getOptions = function() { return options; };
+        plot.highlight = highlight;
+        plot.unhighlight = unhighlight;
+        plot.triggerRedrawOverlay = triggerRedrawOverlay;
+        plot.pointOffset = function(point) {
+            return { left: parseInt(axisSpecToRealAxis(point, "xaxis").p2c(+point.x) + plotOffset.left),
+                     top: parseInt(axisSpecToRealAxis(point, "yaxis").p2c(+point.y) + plotOffset.top) };
+        };
+        
+
+        // public attributes
+        plot.hooks = hooks;
+        
+        // initialize
+        initPlugins(plot);
+        parseOptions(options_);
+        constructCanvas();
+        setData(data_);
+        setupGrid();
+        draw();
+        bindEvents();
+
+
+        function executeHooks(hook, args) {
+            args = [plot].concat(args);
+            for (var i = 0; i < hook.length; ++i)
+                hook[i].apply(this, args);
+        }
+
+        function initPlugins() {
+            for (var i = 0; i < plugins.length; ++i) {
+                var p = plugins[i];
+                p.init(plot);
+                if (p.options)
+                    $.extend(true, options, p.options);
+            }
+        }
+        
+        function parseOptions(opts) {
+            $.extend(true, options, opts);
+            if (options.grid.borderColor == null)
+                options.grid.borderColor = options.grid.color;
+            // backwards compatibility, to be removed in future
+            if (options.xaxis.noTicks && options.xaxis.ticks == null)
+                options.xaxis.ticks = options.xaxis.noTicks;
+            if (options.yaxis.noTicks && options.yaxis.ticks == null)
+                options.yaxis.ticks = options.yaxis.noTicks;
+            if (options.grid.coloredAreas)
+                options.grid.markings = options.grid.coloredAreas;
+            if (options.grid.coloredAreasColor)
+                options.grid.markingsColor = options.grid.coloredAreasColor;
+            if (options.lines)
+                $.extend(true, options.series.lines, options.lines);
+            if (options.points)
+                $.extend(true, options.series.points, options.points);
+            if (options.bars)
+                $.extend(true, options.series.bars, options.bars);
+            if (options.shadowSize)
+                options.series.shadowSize = options.shadowSize;
+
+            for (var n in hooks)
+                if (options.hooks[n] && options.hooks[n].length)
+                    hooks[n] = hooks[n].concat(options.hooks[n]);
+
+            executeHooks(hooks.processOptions, [options]);
+        }
+
+        function setData(d) {
+            series = parseData(d);
+            fillInSeriesOptions();
+            processData();
+        }
+        
+        function parseData(d) {
+            var res = [];
+            for (var i = 0; i < d.length; ++i) {
+                var s = $.extend(true, {}, options.series);
+
+                if (d[i].data) {
+                    s.data = d[i].data; // move the data instead of deep-copy
+                    delete d[i].data;
+
+                    $.extend(true, s, d[i]);
+
+                    d[i].data = s.data;
+                }
+                else
+                    s.data = d[i];
+                res.push(s);
+            }
+
+            return res;
+        }
+        
+        function axisSpecToRealAxis(obj, attr) {
+            var a = obj[attr];
+            if (!a || a == 1)
+                return axes[attr];
+            if (typeof a == "number")
+                return axes[attr.charAt(0) + a + attr.slice(1)];
+            return a; // assume it's OK
+        }
+        
+        function fillInSeriesOptions() {
+            var i;
+            
+            // collect what we already got of colors
+            var neededColors = series.length,
+                usedColors = [],
+                assignedColors = [];
+            for (i = 0; i < series.length; ++i) {
+                var sc = series[i].color;
+                if (sc != null) {
+                    --neededColors;
+                    if (typeof sc == "number")
+                        assignedColors.push(sc);
+                    else
+                        usedColors.push($.color.parse(series[i].color));
+                }
+            }
+            
+            // we might need to generate more colors if higher indices
+            // are assigned
+            for (i = 0; i < assignedColors.length; ++i) {
+                neededColors = Math.max(neededColors, assignedColors[i] + 1);
+            }
+
+            // produce colors as needed
+            var colors = [], variation = 0;
+            i = 0;
+            while (colors.length < neededColors) {
+                var c;
+                if (options.colors.length == i) // check degenerate case
+                    c = $.color.make(100, 100, 100);
+                else
+                    c = $.color.parse(options.colors[i]);
+
+                // vary color if needed
+                var sign = variation % 2 == 1 ? -1 : 1;
+                c.scale('rgb', 1 + sign * Math.ceil(variation / 2) * 0.2)
+
+                // FIXME: if we're getting to close to something else,
+                // we should probably skip this one
+                colors.push(c);
+                
+                ++i;
+                if (i >= options.colors.length) {
+                    i = 0;
+                    ++variation;
+                }
+            }
+
+            // fill in the options
+            var colori = 0, s;
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+                
+                // assign colors
+                if (s.color == null) {
+                    s.color = colors[colori].toString();
+                    ++colori;
+                }
+                else if (typeof s.color == "number")
+                    s.color = colors[s.color].toString();
+
+                // turn on lines automatically in case nothing is set
+                if (s.lines.show == null) {
+                    var v, show = true;
+                    for (v in s)
+                        if (s[v].show) {
+                            show = false;
+                            break;
+                        }
+                    if (show)
+                        s.lines.show = true;
+                }
+
+                // setup axes
+                s.xaxis = axisSpecToRealAxis(s, "xaxis");
+                s.yaxis = axisSpecToRealAxis(s, "yaxis");
+            }
+        }
+        
+        function processData() {
+            var topSentry = Number.POSITIVE_INFINITY,
+                bottomSentry = Number.NEGATIVE_INFINITY,
+                i, j, k, m, length,
+                s, points, ps, x, y, axis, val, f, p;
+
+            for (axis in axes) {
+                axes[axis].datamin = topSentry;
+                axes[axis].datamax = bottomSentry;
+                axes[axis].used = false;
+            }
+
+            function updateAxis(axis, min, max) {
+                if (min < axis.datamin)
+                    axis.datamin = min;
+                if (max > axis.datamax)
+                    axis.datamax = max;
+            }
+
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+                s.datapoints = { points: [] };
+                
+                executeHooks(hooks.processRawData, [ s, s.data, s.datapoints ]);
+            }
+            
+            // first pass: clean and copy data
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+
+                var data = s.data, format = s.datapoints.format;
+
+                if (!format) {
+                    format = [];
+                    // find out how to copy
+                    format.push({ x: true, number: true, required: true });
+                    format.push({ y: true, number: true, required: true });
+
+                    if (s.bars.show)
+                        format.push({ y: true, number: true, required: false, defaultValue: 0 });
+                    
+                    s.datapoints.format = format;
+                }
+
+                if (s.datapoints.pointsize != null)
+                    continue; // already filled in
+
+                if (s.datapoints.pointsize == null)
+                    s.datapoints.pointsize = format.length;
+                
+                ps = s.datapoints.pointsize;
+                points = s.datapoints.points;
+
+                insertSteps = s.lines.show && s.lines.steps;
+                s.xaxis.used = s.yaxis.used = true;
+                
+                for (j = k = 0; j < data.length; ++j, k += ps) {
+                    p = data[j];
+
+                    var nullify = p == null;
+                    if (!nullify) {
+                        for (m = 0; m < ps; ++m) {
+                            val = p[m];
+                            f = format[m];
+
+                            if (f) {
+                                if (f.number && val != null) {
+                                    val = +val; // convert to number
+                                    if (isNaN(val))
+                                        val = null;
+                                }
+
+                                if (val == null) {
+                                    if (f.required)
+                                        nullify = true;
+                                    
+                                    if (f.defaultValue != null)
+                                        val = f.defaultValue;
+                                }
+                            }
+                            
+                            points[k + m] = val;
+                        }
+                    }
+                    
+                    if (nullify) {
+                        for (m = 0; m < ps; ++m) {
+                            val = points[k + m];
+                            if (val != null) {
+                                f = format[m];
+                                // extract min/max info
+                                if (f.x)
+                                    updateAxis(s.xaxis, val, val);
+                                if (f.y)
+                                    updateAxis(s.yaxis, val, val);
+                            }
+                            points[k + m] = null;
+                        }
+                    }
+                    else {
+                        // a little bit of line specific stuff that
+                        // perhaps shouldn't be here, but lacking
+                        // better means...
+                        if (insertSteps && k > 0
+                            && points[k - ps] != null
+                            && points[k - ps] != points[k]
+                            && points[k - ps + 1] != points[k + 1]) {
+                            // copy the point to make room for a middle point
+                            for (m = 0; m < ps; ++m)
+                                points[k + ps + m] = points[k + m];
+
+                            // middle point has same y
+                            points[k + 1] = points[k - ps + 1];
+
+                            // we've added a point, better reflect that
+                            k += ps;
+                        }
+                    }
+                }
+            }
+
+            // give the hooks a chance to run
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+                
+                executeHooks(hooks.processDatapoints, [ s, s.datapoints]);
+            }
+
+            // second pass: find datamax/datamin for auto-scaling
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+                points = s.datapoints.points,
+                ps = s.datapoints.pointsize;
+
+                var xmin = topSentry, ymin = topSentry,
+                    xmax = bottomSentry, ymax = bottomSentry;
+                
+                for (j = 0; j < points.length; j += ps) {
+                    if (points[j] == null)
+                        continue;
+
+                    for (m = 0; m < ps; ++m) {
+                        val = points[j + m];
+                        f = format[m];
+                        if (!f)
+                            continue;
+                        
+                        if (f.x) {
+                            if (val < xmin)
+                                xmin = val;
+                            if (val > xmax)
+                                xmax = val;
+                        }
+                        if (f.y) {
+                            if (val < ymin)
+                                ymin = val;
+                            if (val > ymax)
+                                ymax = val;
+                        }
+                    }
+                }
+                
+                if (s.bars.show) {
+                    // make sure we got room for the bar on the dancing floor
+                    var delta = s.bars.align == "left" ? 0 : -s.bars.barWidth/2;
+                    if (s.bars.horizontal) {
+                        ymin += delta;
+                        ymax += delta + s.bars.barWidth;
+                    }
+                    else {
+                        xmin += delta;
+                        xmax += delta + s.bars.barWidth;
+                    }
+                }
+                
+                updateAxis(s.xaxis, xmin, xmax);
+                updateAxis(s.yaxis, ymin, ymax);
+            }
+
+            for (axis in axes) {
+                if (axes[axis].datamin == topSentry)
+                    axes[axis].datamin = null;
+                if (axes[axis].datamax == bottomSentry)
+                    axes[axis].datamax = null;
+            }
+        }
+
+        function constructCanvas() {
+            function makeCanvas(width, height) {
+                var c = document.createElement('canvas');
+                c.width = width;
+                c.height = height;
+                if ($.browser.msie) // excanvas hack
+                    c = window.G_vmlCanvasManager.initElement(c);
+                return c;
+            }
+            
+            canvasWidth = placeholder.width();
+            canvasHeight = placeholder.height();
+            placeholder.html(""); // clear placeholder
+            if (placeholder.css("position") == 'static')
+                placeholder.css("position", "relative"); // for positioning labels and overlay
+
+            if (canvasWidth <= 0 || canvasHeight <= 0)
+                throw "Invalid dimensions for plot, width = " + canvasWidth + ", height = " + canvasHeight;
+
+            if ($.browser.msie) // excanvas hack
+                window.G_vmlCanvasManager.init_(document); // make sure everything is setup
+            
+            // the canvas
+            canvas = $(makeCanvas(canvasWidth, canvasHeight)).appendTo(placeholder).get(0);
+            ctx = canvas.getContext("2d");
+
+            // overlay canvas for interactive features
+            overlay = $(makeCanvas(canvasWidth, canvasHeight)).css({ position: 'absolute', left: 0, top: 0 }).appendTo(placeholder).get(0);
+            octx = overlay.getContext("2d");
+            octx.stroke();
+        }
+
+        function bindEvents() {
+            // we include the canvas in the event holder too, because IE 7
+            // sometimes has trouble with the stacking order
+            eventHolder = $([overlay, canvas]);
+
+            // bind events
+            if (options.grid.hoverable)
+                eventHolder.mousemove(onMouseMove);
+
+            if (options.grid.clickable)
+                eventHolder.click(onClick);
+
+            executeHooks(hooks.bindEvents, [eventHolder]);
+        }
+
+        function setupGrid() {
+            function setTransformationHelpers(axis, o) {
+                function identity(x) { return x; }
+                
+                var s, m, t = o.transform || identity,
+                    it = o.inverseTransform;
+                    
+                // add transformation helpers
+                if (axis == axes.xaxis || axis == axes.x2axis) {
+                    // precompute how much the axis is scaling a point
+                    // in canvas space
+                    s = axis.scale = plotWidth / (t(axis.max) - t(axis.min));
+                    m = t(axis.min);
+
+                    // data point to canvas coordinate
+                    if (t == identity) // slight optimization
+                        axis.p2c = function (p) { return (p - m) * s; };
+                    else
+                        axis.p2c = function (p) { return (t(p) - m) * s; };
+                    // canvas coordinate to data point
+                    if (!it)
+                        axis.c2p = function (c) { return m + c / s; };
+                    else
+                        axis.c2p = function (c) { return it(m + c / s); };
+                }
+                else {
+                    s = axis.scale = plotHeight / (t(axis.max) - t(axis.min));
+                    m = t(axis.max);
+                    
+                    if (t == identity)
+                        axis.p2c = function (p) { return (m - p) * s; };
+                    else
+                        axis.p2c = function (p) { return (m - t(p)) * s; };
+                    if (!it)
+                        axis.c2p = function (c) { return m - c / s; };
+                    else
+                        axis.c2p = function (c) { return it(m - c / s); };
+                }
+            }
+
+            function measureLabels(axis, axisOptions) {
+                var i, labels = [], l;
+                
+                axis.labelWidth = axisOptions.labelWidth;
+                axis.labelHeight = axisOptions.labelHeight;
+
+                if (axis == axes.xaxis || axis == axes.x2axis) {
+                    // to avoid measuring the widths of the labels, we
+                    // construct fixed-size boxes and put the labels inside
+                    // them, we don't need the exact figures and the
+                    // fixed-size box content is easy to center
+                    if (axis.labelWidth == null)
+                        axis.labelWidth = canvasWidth / (axis.ticks.length > 0 ? axis.ticks.length : 1);
+
+                    // measure x label heights
+                    if (axis.labelHeight == null) {
+                        labels = [];
+                        for (i = 0; i < axis.ticks.length; ++i) {
+                            l = axis.ticks[i].label;
+                            if (l)
+                                labels.push('<div class="tickLabel" style="float:left;width:' + axis.labelWidth + 'px">' + l + '</div>');
+                        }
+                        
+                        if (labels.length > 0) {
+                            var dummyDiv = $('<div style="position:absolute;top:-10000px;width:10000px;font-size:smaller">'
+                                             + labels.join("") + '<div style="clear:left"></div></div>').appendTo(placeholder);
+                            axis.labelHeight = dummyDiv.height();
+                            dummyDiv.remove();
+                        }
+                    }
+                }
+                else if (axis.labelWidth == null || axis.labelHeight == null) {
+                    // calculate y label dimensions
+                    for (i = 0; i < axis.ticks.length; ++i) {
+                        l = axis.ticks[i].label;
+                        if (l)
+                            labels.push('<div class="tickLabel">' + l + '</div>');
+                    }
+                    
+                    if (labels.length > 0) {
+                        var dummyDiv = $('<div style="position:absolute;top:-10000px;font-size:smaller">'
+                                         + labels.join("") + '</div>').appendTo(placeholder);
+                        if (axis.labelWidth == null)
+                            axis.labelWidth = dummyDiv.width();
+                        if (axis.labelHeight == null)
+                            axis.labelHeight = dummyDiv.find("div").height();
+                        dummyDiv.remove();
+                    }
+                    
+                }
+
+                if (axis.labelWidth == null)
+                    axis.labelWidth = 0;
+                if (axis.labelHeight == null)
+                    axis.labelHeight = 0;
+            }
+            
+            function setGridSpacing() {
+                // get the most space needed around the grid for things
+                // that may stick out
+                var maxOutset = options.grid.borderWidth;
+                for (i = 0; i < series.length; ++i)
+                    maxOutset = Math.max(maxOutset, 2 * (series[i].points.radius + series[i].points.lineWidth/2));
+                
+                plotOffset.left = plotOffset.right = plotOffset.top = plotOffset.bottom = maxOutset;
+                
+                var margin = options.grid.labelMargin + options.grid.borderWidth;
+                
+                if (axes.xaxis.labelHeight > 0)
+                    plotOffset.bottom = Math.max(maxOutset, axes.xaxis.labelHeight + margin);
+                if (axes.yaxis.labelWidth > 0)
+                    plotOffset.left = Math.max(maxOutset, axes.yaxis.labelWidth + margin);
+                if (axes.x2axis.labelHeight > 0)
+                    plotOffset.top = Math.max(maxOutset, axes.x2axis.labelHeight + margin);
+                if (axes.y2axis.labelWidth > 0)
+                    plotOffset.right = Math.max(maxOutset, axes.y2axis.labelWidth + margin);
+            
+                plotWidth = canvasWidth - plotOffset.left - plotOffset.right;
+                plotHeight = canvasHeight - plotOffset.bottom - plotOffset.top;
+            }
+            
+            var axis;
+            for (axis in axes)
+                setRange(axes[axis], options[axis]);
+            
+            if (options.grid.show) {
+                for (axis in axes) {
+                    prepareTickGeneration(axes[axis], options[axis]);
+                    setTicks(axes[axis], options[axis]);
+                    measureLabels(axes[axis], options[axis]);
+                }
+
+                setGridSpacing();
+            }
+            else {
+                plotOffset.left = plotOffset.right = plotOffset.top = plotOffset.bottom = 0;
+                plotWidth = canvasWidth;
+                plotHeight = canvasHeight;
+            }
+            
+            for (axis in axes)
+                setTransformationHelpers(axes[axis], options[axis]);
+
+            if (options.grid.show)
+                insertLabels();
+            
+            insertLegend();
+        }
+        
+        function setRange(axis, axisOptions) {
+            var min = +(axisOptions.min != null ? axisOptions.min : axis.datamin),
+                max = +(axisOptions.max != null ? axisOptions.max : axis.datamax),
+                delta = max - min;
+
+            if (delta == 0.0) {
+                // degenerate case
+                var widen = max == 0 ? 1 : 0.01;
+
+                if (axisOptions.min == null)
+                    min -= widen;
+                // alway widen max if we couldn't widen min to ensure we
+                // don't fall into min == max which doesn't work
+                if (axisOptions.max == null || axisOptions.min != null)
+                    max += widen;
+            }
+            else {
+                // consider autoscaling
+                var margin = axisOptions.autoscaleMargin;
+                if (margin != null) {
+                    if (axisOptions.min == null) {
+                        min -= delta * margin;
+                        // make sure we don't go below zero if all values
+                        // are positive
+                        if (min < 0 && axis.datamin != null && axis.datamin >= 0)
+                            min = 0;
+                    }
+                    if (axisOptions.max == null) {
+                        max += delta * margin;
+                        if (max > 0 && axis.datamax != null && axis.datamax <= 0)
+                            max = 0;
+                    }
+                }
+            }
+            axis.min = min;
+            axis.max = max;
+        }
+
+        function prepareTickGeneration(axis, axisOptions) {
+            // estimate number of ticks
+            var noTicks;
+            if (typeof axisOptions.ticks == "number" && axisOptions.ticks > 0)
+                noTicks = axisOptions.ticks;
+            else if (axis == axes.xaxis || axis == axes.x2axis)
+                 // heuristic based on the model a*sqrt(x) fitted to
+                 // some reasonable data points
+                noTicks = 0.3 * Math.sqrt(canvasWidth);
+            else
+                noTicks = 0.3 * Math.sqrt(canvasHeight);
+            
+            var delta = (axis.max - axis.min) / noTicks,
+                size, generator, unit, formatter, i, magn, norm;
+
+            if (axisOptions.mode == "time") {
+                // pretty handling of time
+                
+                // map of app. size of time units in milliseconds
+                var timeUnitSize = {
+                    "second": 1000,
+                    "minute": 60 * 1000,
+                    "hour": 60 * 60 * 1000,
+                    "day": 24 * 60 * 60 * 1000,
+                    "month": 30 * 24 * 60 * 60 * 1000,
+                    "year": 365.2425 * 24 * 60 * 60 * 1000
+                };
+
+
+                // the allowed tick sizes, after 1 year we use
+                // an integer algorithm
+                var spec = [
+                    [1, "second"], [2, "second"], [5, "second"], [10, "second"],
+                    [30, "second"], 
+                    [1, "minute"], [2, "minute"], [5, "minute"], [10, "minute"],
+                    [30, "minute"], 
+                    [1, "hour"], [2, "hour"], [4, "hour"],
+                    [8, "hour"], [12, "hour"],
+                    [1, "day"], [2, "day"], [3, "day"],
+                    [0.25, "month"], [0.5, "month"], [1, "month"],
+                    [2, "month"], [3, "month"], [6, "month"],
+                    [1, "year"]
+                ];
+
+                var minSize = 0;
+                if (axisOptions.minTickSize != null) {
+                    if (typeof axisOptions.tickSize == "number")
+                        minSize = axisOptions.tickSize;
+                    else
+                        minSize = axisOptions.minTickSize[0] * timeUnitSize[axisOptions.minTickSize[1]];
+                }
+
+                for (i = 0; i < spec.length - 1; ++i)
+                    if (delta < (spec[i][0] * timeUnitSize[spec[i][1]]
+                                 + spec[i + 1][0] * timeUnitSize[spec[i + 1][1]]) / 2
+                       && spec[i][0] * timeUnitSize[spec[i][1]] >= minSize)
+                        break;
+                size = spec[i][0];
+                unit = spec[i][1];
+                
+                // special-case the possibility of several years
+                if (unit == "year") {
+                    magn = Math.pow(10, Math.floor(Math.log(delta / timeUnitSize.year) / Math.LN10));
+                    norm = (delta / timeUnitSize.year) / magn;
+                    if (norm < 1.5)
+                        size = 1;
+                    else if (norm < 3)
+                        size = 2;
+                    else if (norm < 7.5)
+                        size = 5;
+                    else
+                        size = 10;
+
+                    size *= magn;
+                }
+
+                if (axisOptions.tickSize) {
+                    size = axisOptions.tickSize[0];
+                    unit = axisOptions.tickSize[1];
+                }
+                
+                generator = function(axis) {
+                    var ticks = [],
+                        tickSize = axis.tickSize[0], unit = axis.tickSize[1],
+                        d = new Date(axis.min);
+                    
+                    var step = tickSize * timeUnitSize[unit];
+
+                    if (unit == "second")
+                        d.setUTCSeconds(floorInBase(d.getUTCSeconds(), tickSize));
+                    if (unit == "minute")
+                        d.setUTCMinutes(floorInBase(d.getUTCMinutes(), tickSize));
+                    if (unit == "hour")
+                        d.setUTCHours(floorInBase(d.getUTCHours(), tickSize));
+                    if (unit == "month")
+                        d.setUTCMonth(floorInBase(d.getUTCMonth(), tickSize));
+                    if (unit == "year")
+                        d.setUTCFullYear(floorInBase(d.getUTCFullYear(), tickSize));
+                    
+                    // reset smaller components
+                    d.setUTCMilliseconds(0);
+                    if (step >= timeUnitSize.minute)
+                        d.setUTCSeconds(0);
+                    if (step >= timeUnitSize.hour)
+                        d.setUTCMinutes(0);
+                    if (step >= timeUnitSize.day)
+                        d.setUTCHours(0);
+                    if (step >= timeUnitSize.day * 4)
+                        d.setUTCDate(1);
+                    if (step >= timeUnitSize.year)
+                        d.setUTCMonth(0);
+
+
+                    var carry = 0, v = Number.NaN, prev;
+                    do {
+                        prev = v;
+                        v = d.getTime();
+                        ticks.push({ v: v, label: axis.tickFormatter(v, axis) });
+                        if (unit == "month") {
+                            if (tickSize < 1) {
+                                // a bit complicated - we'll divide the month
+                                // up but we need to take care of fractions
+                                // so we don't end up in the middle of a day
+                                d.setUTCDate(1);
+                                var start = d.getTime();
+                                d.setUTCMonth(d.getUTCMonth() + 1);
+                                var end = d.getTime();
+                                d.setTime(v + carry * timeUnitSize.hour + (end - start) * tickSize);
+                                carry = d.getUTCHours();
+                                d.setUTCHours(0);
+                            }
+                            else
+                                d.setUTCMonth(d.getUTCMonth() + tickSize);
+                        }
+                        else if (unit == "year") {
+                            d.setUTCFullYear(d.getUTCFullYear() + tickSize);
+                        }
+                        else
+                            d.setTime(v + step);
+                    } while (v < axis.max && v != prev);
+
+                    return ticks;
+                };
+
+                formatter = function (v, axis) {
+                    var d = new Date(v);
+
+                    // first check global format
+                    if (axisOptions.timeformat != null)
+                        return $.plot.formatDate(d, axisOptions.timeformat, axisOptions.monthNames);
+                    
+                    var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]];
+                    var span = axis.max - axis.min;
+                    var suffix = (axisOptions.twelveHourClock) ? " %p" : "";
+                    
+                    if (t < timeUnitSize.minute)
+                        fmt = "%h:%M:%S" + suffix;
+                    else if (t < timeUnitSize.day) {
+                        if (span < 2 * timeUnitSize.day)
+                            fmt = "%h:%M" + suffix;
+                        else
+                            fmt = "%b %d %h:%M" + suffix;
+                    }
+                    else if (t < timeUnitSize.month)
+                        fmt = "%b %d";
+                    else if (t < timeUnitSize.year) {
+                        if (span < timeUnitSize.year)
+                            fmt = "%b";
+                        else
+                            fmt = "%b %y";
+                    }
+                    else
+                        fmt = "%y";
+                    
+                    return $.plot.formatDate(d, fmt, axisOptions.monthNames);
+                };
+            }
+            else {
+                // pretty rounding of base-10 numbers
+                var maxDec = axisOptions.tickDecimals;
+                var dec = -Math.floor(Math.log(delta) / Math.LN10);
+                if (maxDec != null && dec > maxDec)
+                    dec = maxDec;
+
+                magn = Math.pow(10, -dec);
+                norm = delta / magn; // norm is between 1.0 and 10.0
+                
+                if (norm < 1.5)
+                    size = 1;
+                else if (norm < 3) {
+                    size = 2;
+                    // special case for 2.5, requires an extra decimal
+                    if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) {
+                        size = 2.5;
+                        ++dec;
+                    }
+                }
+                else if (norm < 7.5)
+                    size = 5;
+                else
+                    size = 10;
+
+                size *= magn;
+                
+                if (axisOptions.minTickSize != null && size < axisOptions.minTickSize)
+                    size = axisOptions.minTickSize;
+
+                if (axisOptions.tickSize != null)
+                    size = axisOptions.tickSize;
+
+                axis.tickDecimals = Math.max(0, (maxDec != null) ? maxDec : dec);
+
+                generator = function (axis) {
+                    var ticks = [];
+
+                    // spew out all possible ticks
+                    var start = floorInBase(axis.min, axis.tickSize),
+                        i = 0, v = Number.NaN, prev;
+                    do {
+                        prev = v;
+                        v = start + i * axis.tickSize;
+                        ticks.push({ v: v, label: axis.tickFormatter(v, axis) });
+                        ++i;
+                    } while (v < axis.max && v != prev);
+                    return ticks;
+                };
+
+                formatter = function (v, axis) {
+                    return v.toFixed(axis.tickDecimals);
+                };
+            }
+
+            axis.tickSize = unit ? [size, unit] : size;
+            axis.tickGenerator = generator;
+            if ($.isFunction(axisOptions.tickFormatter))
+                axis.tickFormatter = function (v, axis) { return "" + axisOptions.tickFormatter(v, axis); };
+            else
+                axis.tickFormatter = formatter;
+        }
+        
+        function setTicks(axis, axisOptions) {
+            axis.ticks = [];
+
+            if (!axis.used)
+                return;
+            
+            if (axisOptions.ticks == null)
+                axis.ticks = axis.tickGenerator(axis);
+            else if (typeof axisOptions.ticks == "number") {
+                if (axisOptions.ticks > 0)
+                    axis.ticks = axis.tickGenerator(axis);
+            }
+            else if (axisOptions.ticks) {
+                var ticks = axisOptions.ticks;
+
+                if ($.isFunction(ticks))
+                    // generate the ticks
+                    ticks = ticks({ min: axis.min, max: axis.max });
+                
+                // clean up the user-supplied ticks, copy them over
+                var i, v;
+                for (i = 0; i < ticks.length; ++i) {
+                    var label = null;
+                    var t = ticks[i];
+                    if (typeof t == "object") {
+                        v = t[0];
+                        if (t.length > 1)
+                            label = t[1];
+                    }
+                    else
+                        v = t;
+                    if (label == null)
+                        label = axis.tickFormatter(v, axis);
+                    axis.ticks[i] = { v: v, label: label };
+                }
+            }
+
+            if (axisOptions.autoscaleMargin != null && axis.ticks.length > 0) {
+                // snap to ticks
+                if (axisOptions.min == null)
+                    axis.min = Math.min(axis.min, axis.ticks[0].v);
+                if (axisOptions.max == null && axis.ticks.length > 1)
+                    axis.max = Math.max(axis.max, axis.ticks[axis.ticks.length - 1].v);
+            }
+        }
+      
+        function draw() {
+            ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+
+            var grid = options.grid;
+            
+            if (grid.show && !grid.aboveData)
+                drawGrid();
+
+            for (var i = 0; i < series.length; ++i)
+                drawSeries(series[i]);
+
+            executeHooks(hooks.draw, [ctx]);
+            
+            if (grid.show && grid.aboveData)
+                drawGrid();
+        }
+
+        function extractRange(ranges, coord) {
+            var firstAxis = coord + "axis",
+                secondaryAxis = coord + "2axis",
+                axis, from, to, reverse;
+
+            if (ranges[firstAxis]) {
+                axis = axes[firstAxis];
+                from = ranges[firstAxis].from;
+                to = ranges[firstAxis].to;
+            }
+            else if (ranges[secondaryAxis]) {
+                axis = axes[secondaryAxis];
+                from = ranges[secondaryAxis].from;
+                to = ranges[secondaryAxis].to;
+            }
+            else {
+                // backwards-compat stuff - to be removed in future
+                axis = axes[firstAxis];
+                from = ranges[coord + "1"];
+                to = ranges[coord + "2"];
+            }
+
+            // auto-reverse as an added bonus
+            if (from != null && to != null && from > to)
+                return { from: to, to: from, axis: axis };
+            
+            return { from: from, to: to, axis: axis };
+        }
+        
+        function drawGrid() {
+            var i;
+            
+            ctx.save();
+            ctx.translate(plotOffset.left, plotOffset.top);
+
+            // draw background, if any
+            if (options.grid.backgroundColor) {
+                ctx.fillStyle = getColorOrGradient(options.grid.backgroundColor, plotHeight, 0, "rgba(255, 255, 255, 0)");
+                ctx.fillRect(0, 0, plotWidth, plotHeight);
+            }
+
+            // draw markings
+            var markings = options.grid.markings;
+            if (markings) {
+                if ($.isFunction(markings))
+                    // xmin etc. are backwards-compatible, to be removed in future
+                    markings = markings({ xmin: axes.xaxis.min, xmax: axes.xaxis.max, ymin: axes.yaxis.min, ymax: axes.yaxis.max, xaxis: axes.xaxis, yaxis: axes.yaxis, x2axis: axes.x2axis, y2axis: axes.y2axis });
+
+                for (i = 0; i < markings.length; ++i) {
+                    var m = markings[i],
+                        xrange = extractRange(m, "x"),
+                        yrange = extractRange(m, "y");
+
+                    // fill in missing
+                    if (xrange.from == null)
+                        xrange.from = xrange.axis.min;
+                    if (xrange.to == null)
+                        xrange.to = xrange.axis.max;
+                    if (yrange.from == null)
+                        yrange.from = yrange.axis.min;
+                    if (yrange.to == null)
+                        yrange.to = yrange.axis.max;
+
+                    // clip
+                    if (xrange.to < xrange.axis.min || xrange.from > xrange.axis.max ||
+                        yrange.to < yrange.axis.min || yrange.from > yrange.axis.max)
+                        continue;
+
+                    xrange.from = Math.max(xrange.from, xrange.axis.min);
+                    xrange.to = Math.min(xrange.to, xrange.axis.max);
+                    yrange.from = Math.max(yrange.from, yrange.axis.min);
+                    yrange.to = Math.min(yrange.to, yrange.axis.max);
+
+                    if (xrange.from == xrange.to && yrange.from == yrange.to)
+                        continue;
+
+                    // then draw
+                    xrange.from = xrange.axis.p2c(xrange.from);
+                    xrange.to = xrange.axis.p2c(xrange.to);
+                    yrange.from = yrange.axis.p2c(yrange.from);
+                    yrange.to = yrange.axis.p2c(yrange.to);
+                    
+                    if (xrange.from == xrange.to || yrange.from == yrange.to) {
+                        // draw line
+                        ctx.beginPath();
+                        ctx.strokeStyle = m.color || options.grid.markingsColor;
+                        ctx.lineWidth = m.lineWidth || options.grid.markingsLineWidth;
+                        //ctx.moveTo(Math.floor(xrange.from), yrange.from);
+                        //ctx.lineTo(Math.floor(xrange.to), yrange.to);
+                        ctx.moveTo(xrange.from, yrange.from);
+                        ctx.lineTo(xrange.to, yrange.to);
+                        ctx.stroke();
+                    }
+                    else {
+                        // fill area
+                        ctx.fillStyle = m.color || options.grid.markingsColor;
+                        ctx.fillRect(xrange.from, yrange.to,
+                                     xrange.to - xrange.from,
+                                     yrange.from - yrange.to);
+                    }
+                }
+            }
+            
+            // draw the inner grid
+            ctx.lineWidth = 1;
+            ctx.strokeStyle = options.grid.tickColor;
+            ctx.beginPath();
+            var v, axis = axes.xaxis;
+            for (i = 0; i < axis.ticks.length; ++i) {
+                v = axis.ticks[i].v;
+                if (v <= axis.min || v >= axes.xaxis.max)
+                    continue;   // skip those lying on the axes
+
+                ctx.moveTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, 0);
+                ctx.lineTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, plotHeight);
+            }
+
+            axis = axes.yaxis;
+            for (i = 0; i < axis.ticks.length; ++i) {
+                v = axis.ticks[i].v;
+                if (v <= axis.min || v >= axis.max)
+                    continue;
+
+                ctx.moveTo(0, Math.floor(axis.p2c(v)) + ctx.lineWidth/2);
+                ctx.lineTo(plotWidth, Math.floor(axis.p2c(v)) + ctx.lineWidth/2);
+            }
+
+            axis = axes.x2axis;
+            for (i = 0; i < axis.ticks.length; ++i) {
+                v = axis.ticks[i].v;
+                if (v <= axis.min || v >= axis.max)
+                    continue;
+    
+                ctx.moveTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, -5);
+                ctx.lineTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, 5);
+            }
+
+            axis = axes.y2axis;
+            for (i = 0; i < axis.ticks.length; ++i) {
+                v = axis.ticks[i].v;
+                if (v <= axis.min || v >= axis.max)
+                    continue;
+
+                ctx.moveTo(plotWidth-5, Math.floor(axis.p2c(v)) + ctx.lineWidth/2);
+                ctx.lineTo(plotWidth+5, Math.floor(axis.p2c(v)) + ctx.lineWidth/2);
+            }
+            
+            ctx.stroke();
+            
+            if (options.grid.borderWidth) {
+                // draw border
+                var bw = options.grid.borderWidth;
+                ctx.lineWidth = bw;
+                ctx.strokeStyle = options.grid.borderColor;
+                ctx.strokeRect(-bw/2, -bw/2, plotWidth + bw, plotHeight + bw);
+            }
+
+            ctx.restore();
+        }
+
+        function insertLabels() {
+            placeholder.find(".tickLabels").remove();
+            
+            var html = ['<div class="tickLabels" style="font-size:smaller;color:' + options.grid.color + '">'];
+
+            function addLabels(axis, labelGenerator) {
+                for (var i = 0; i < axis.ticks.length; ++i) {
+                    var tick = axis.ticks[i];
+                    if (!tick.label || tick.v < axis.min || tick.v > axis.max)
+                        continue;
+                    html.push(labelGenerator(tick, axis));
+                }
+            }
+
+            var margin = options.grid.labelMargin + options.grid.borderWidth;
+            
+            addLabels(axes.xaxis, function (tick, axis) {
+                return '<div style="position:absolute;top:' + (plotOffset.top + plotHeight + margin) + 'px;left:' + Math.round(plotOffset.left + axis.p2c(tick.v) - axis.labelWidth/2) + 'px;width:' + axis.labelWidth + 'px;text-align:center" class="tickLabel">' + tick.label + "</div>";
+            });
+            
+            
+            addLabels(axes.yaxis, function (tick, axis) {
+                return '<div style="position:absolute;top:' + Math.round(plotOffset.top + axis.p2c(tick.v) - axis.labelHeight/2) + 'px;right:' + (plotOffset.right + plotWidth + margin) + 'px;width:' + axis.labelWidth + 'px;text-align:right" class="tickLabel">' + tick.label + "</div>";
+            });
+            
+            addLabels(axes.x2axis, function (tick, axis) {
+                return '<div style="position:absolute;bottom:' + (plotOffset.bottom + plotHeight + margin) + 'px;left:' + Math.round(plotOffset.left + axis.p2c(tick.v) - axis.labelWidth/2) + 'px;width:' + axis.labelWidth + 'px;text-align:center" class="tickLabel">' + tick.label + "</div>";
+            });
+            
+            addLabels(axes.y2axis, function (tick, axis) {
+                return '<div style="position:absolute;top:' + Math.round(plotOffset.top + axis.p2c(tick.v) - axis.labelHeight/2) + 'px;left:' + (plotOffset.left + plotWidth + margin) +'px;width:' + axis.labelWidth + 'px;text-align:left" class="tickLabel">' + tick.label + "</div>";
+            });
+
+            html.push('</div>');
+            
+            placeholder.append(html.join(""));
+        }
+
+        function drawSeries(series) {
+            if (series.lines.show)
+                drawSeriesLines(series);
+            if (series.bars.show)
+                drawSeriesBars(series);
+            if (series.points.show)
+                drawSeriesPoints(series);
+        }
+        
+        function drawSeriesLines(series) {
+            function plotLine(datapoints, xoffset, yoffset, axisx, axisy) {
+                var points = datapoints.points,
+                    ps = datapoints.pointsize,
+                    prevx = null, prevy = null;
+                
+                ctx.beginPath();
+                for (var i = ps; i < points.length; i += ps) {
+                    var x1 = points[i - ps], y1 = points[i - ps + 1],
+                        x2 = points[i], y2 = points[i + 1];
+                    
+                    if (x1 == null || x2 == null)
+                        continue;
+
+                    // clip with ymin
+                    if (y1 <= y2 && y1 < axisy.min) {
+                        if (y2 < axisy.min)
+                            continue;   // line segment is outside
+                        // compute new intersection point
+                        x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y1 = axisy.min;
+                    }
+                    else if (y2 <= y1 && y2 < axisy.min) {
+                        if (y1 < axisy.min)
+                            continue;
+                        x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y2 = axisy.min;
+                    }
+
+                    // clip with ymax
+                    if (y1 >= y2 && y1 > axisy.max) {
+                        if (y2 > axisy.max)
+                            continue;
+                        x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y1 = axisy.max;
+                    }
+                    else if (y2 >= y1 && y2 > axisy.max) {
+                        if (y1 > axisy.max)
+                            continue;
+                        x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y2 = axisy.max;
+                    }
+
+                    // clip with xmin
+                    if (x1 <= x2 && x1 < axisx.min) {
+                        if (x2 < axisx.min)
+                            continue;
+                        y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x1 = axisx.min;
+                    }
+                    else if (x2 <= x1 && x2 < axisx.min) {
+                        if (x1 < axisx.min)
+                            continue;
+                        y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x2 = axisx.min;
+                    }
+
+                    // clip with xmax
+                    if (x1 >= x2 && x1 > axisx.max) {
+                        if (x2 > axisx.max)
+                            continue;
+                        y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x1 = axisx.max;
+                    }
+                    else if (x2 >= x1 && x2 > axisx.max) {
+                        if (x1 > axisx.max)
+                            continue;
+                        y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x2 = axisx.max;
+                    }
+
+                    if (x1 != prevx || y1 != prevy)
+                        ctx.moveTo(axisx.p2c(x1) + xoffset, axisy.p2c(y1) + yoffset);
+                    
+                    prevx = x2;
+                    prevy = y2;
+                    ctx.lineTo(axisx.p2c(x2) + xoffset, axisy.p2c(y2) + yoffset);
+                }
+                ctx.stroke();
+            }
+
+            function plotLineArea(datapoints, axisx, axisy) {
+                var points = datapoints.points,
+                    ps = datapoints.pointsize,
+                    bottom = Math.min(Math.max(0, axisy.min), axisy.max),
+                    top, lastX = 0, areaOpen = false;
+                
+                for (var i = ps; i < points.length; i += ps) {
+                    var x1 = points[i - ps], y1 = points[i - ps + 1],
+                        x2 = points[i], y2 = points[i + 1];
+                    
+                    if (areaOpen && x1 != null && x2 == null) {
+                        // close area
+                        ctx.lineTo(axisx.p2c(lastX), axisy.p2c(bottom));
+                        ctx.fill();
+                        areaOpen = false;
+                        continue;
+                    }
+
+                    if (x1 == null || x2 == null)
+                        continue;
+
+                    // clip x values
+                    
+                    // clip with xmin
+                    if (x1 <= x2 && x1 < axisx.min) {
+                        if (x2 < axisx.min)
+                            continue;
+                        y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x1 = axisx.min;
+                    }
+                    else if (x2 <= x1 && x2 < axisx.min) {
+                        if (x1 < axisx.min)
+                            continue;
+                        y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x2 = axisx.min;
+                    }
+
+                    // clip with xmax
+                    if (x1 >= x2 && x1 > axisx.max) {
+                        if (x2 > axisx.max)
+                            continue;
+                        y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x1 = axisx.max;
+                    }
+                    else if (x2 >= x1 && x2 > axisx.max) {
+                        if (x1 > axisx.max)
+                            continue;
+                        y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1;
+                        x2 = axisx.max;
+                    }
+
+                    if (!areaOpen) {
+                        // open area
+                        ctx.beginPath();
+                        ctx.moveTo(axisx.p2c(x1), axisy.p2c(bottom));
+                        areaOpen = true;
+                    }
+                    
+                    // now first check the case where both is outside
+                    if (y1 >= axisy.max && y2 >= axisy.max) {
+                        ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.max));
+                        ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.max));
+                        lastX = x2;
+                        continue;
+                    }
+                    else if (y1 <= axisy.min && y2 <= axisy.min) {
+                        ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.min));
+                        ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.min));
+                        lastX = x2;
+                        continue;
+                    }
+                    
+                    // else it's a bit more complicated, there might
+                    // be two rectangles and two triangles we need to fill
+                    // in; to find these keep track of the current x values
+                    var x1old = x1, x2old = x2;
+
+                    // and clip the y values, without shortcutting
+                    
+                    // clip with ymin
+                    if (y1 <= y2 && y1 < axisy.min && y2 >= axisy.min) {
+                        x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y1 = axisy.min;
+                    }
+                    else if (y2 <= y1 && y2 < axisy.min && y1 >= axisy.min) {
+                        x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y2 = axisy.min;
+                    }
+
+                    // clip with ymax
+                    if (y1 >= y2 && y1 > axisy.max && y2 <= axisy.max) {
+                        x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y1 = axisy.max;
+                    }
+                    else if (y2 >= y1 && y2 > axisy.max && y1 <= axisy.max) {
+                        x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1;
+                        y2 = axisy.max;
+                    }
+
+
+                    // if the x value was changed we got a rectangle
+                    // to fill
+                    if (x1 != x1old) {
+                        if (y1 <= axisy.min)
+                            top = axisy.min;
+                        else
+                            top = axisy.max;
+                        
+                        ctx.lineTo(axisx.p2c(x1old), axisy.p2c(top));
+                        ctx.lineTo(axisx.p2c(x1), axisy.p2c(top));
+                    }
+                    
+                    // fill the triangles
+                    ctx.lineTo(axisx.p2c(x1), axisy.p2c(y1));
+                    ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2));
+
+                    // fill the other rectangle if it's there
+                    if (x2 != x2old) {
+                        if (y2 <= axisy.min)
+                            top = axisy.min;
+                        else
+                            top = axisy.max;
+                        
+                        ctx.lineTo(axisx.p2c(x2), axisy.p2c(top));
+                        ctx.lineTo(axisx.p2c(x2old), axisy.p2c(top));
+                    }
+
+                    lastX = Math.max(x2, x2old);
+                }
+
+                if (areaOpen) {
+                    ctx.lineTo(axisx.p2c(lastX), axisy.p2c(bottom));
+                    ctx.fill();
+                }
+            }
+            
+            ctx.save();
+            ctx.translate(plotOffset.left, plotOffset.top);
+            ctx.lineJoin = "round";
+
+            var lw = series.lines.lineWidth,
+                sw = series.shadowSize;
+            // FIXME: consider another form of shadow when filling is turned on
+            if (lw > 0 && sw > 0) {
+                // draw shadow as a thick and thin line with transparency
+                ctx.lineWidth = sw;
+                ctx.strokeStyle = "rgba(0,0,0,0.1)";
+                // position shadow at angle from the mid of line
+                var angle = Math.PI/18;
+                plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/2), Math.cos(angle) * (lw/2 + sw/2), series.xaxis, series.yaxis);
+                ctx.lineWidth = sw/2;
+                plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/4), Math.cos(angle) * (lw/2 + sw/4), series.xaxis, series.yaxis);
+            }
+
+            ctx.lineWidth = lw;
+            ctx.strokeStyle = series.color;
+            var fillStyle = getFillStyle(series.lines, series.color, 0, plotHeight);
+            if (fillStyle) {
+                ctx.fillStyle = fillStyle;
+                plotLineArea(series.datapoints, series.xaxis, series.yaxis);
+            }
+
+            if (lw > 0)
+                plotLine(series.datapoints, 0, 0, series.xaxis, series.yaxis);
+            ctx.restore();
+        }
+
+        function drawSeriesPoints(series) {
+            function plotPoints(datapoints, radius, fillStyle, offset, circumference, axisx, axisy) {
+                var points = datapoints.points, ps = datapoints.pointsize;
+                
+                for (var i = 0; i < points.length; i += ps) {
+                    var x = points[i], y = points[i + 1];
+                    if (x == null || x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max)
+                        continue;
+                    
+                    ctx.beginPath();
+                    ctx.arc(axisx.p2c(x), axisy.p2c(y) + offset, radius, 0, circumference, false);
+                    if (fillStyle) {
+                        ctx.fillStyle = fillStyle;
+                        ctx.fill();
+                    }
+                    ctx.stroke();
+                }
+            }
+            
+            ctx.save();
+            ctx.translate(plotOffset.left, plotOffset.top);
+
+            var lw = series.lines.lineWidth,
+                sw = series.shadowSize,
+                radius = series.points.radius;
+            if (lw > 0 && sw > 0) {
+                // draw shadow in two steps
+                var w = sw / 2;
+                ctx.lineWidth = w;
+                ctx.strokeStyle = "rgba(0,0,0,0.1)";
+                plotPoints(series.datapoints, radius, null, w + w/2, Math.PI,
+                           series.xaxis, series.yaxis);
+
+                ctx.strokeStyle = "rgba(0,0,0,0.2)";
+                plotPoints(series.datapoints, radius, null, w/2, Math.PI,
+                           series.xaxis, series.yaxis);
+            }
+
+            ctx.lineWidth = lw;
+            ctx.strokeStyle = series.color;
+            plotPoints(series.datapoints, radius,
+                       getFillStyle(series.points, series.color), 0, 2 * Math.PI,
+                       series.xaxis, series.yaxis);
+            ctx.restore();
+        }
+
+        function drawBar(x, y, b, barLeft, barRight, offset, fillStyleCallback, axisx, axisy, c, horizontal) {
+            var left, right, bottom, top,
+                drawLeft, drawRight, drawTop, drawBottom,
+                tmp;
+
+            if (horizontal) {
+                drawBottom = drawRight = drawTop = true;
+                drawLeft = false;
+                left = b;
+                right = x;
+                top = y + barLeft;
+                bottom = y + barRight;
+
+                // account for negative bars
+                if (right < left) {
+                    tmp = right;
+                    right = left;
+                    left = tmp;
+                    drawLeft = true;
+                    drawRight = false;
+                }
+            }
+            else {
+                drawLeft = drawRight = drawTop = true;
+                drawBottom = false;
+                left = x + barLeft;
+                right = x + barRight;
+                bottom = b;
+                top = y;
+
+                // account for negative bars
+                if (top < bottom) {
+                    tmp = top;
+                    top = bottom;
+                    bottom = tmp;
+                    drawBottom = true;
+                    drawTop = false;
+                }
+            }
+           
+            // clip
+            if (right < axisx.min || left > axisx.max ||
+                top < axisy.min || bottom > axisy.max)
+                return;
+            
+            if (left < axisx.min) {
+                left = axisx.min;
+                drawLeft = false;
+            }
+
+            if (right > axisx.max) {
+                right = axisx.max;
+                drawRight = false;
+            }
+
+            if (bottom < axisy.min) {
+                bottom = axisy.min;
+                drawBottom = false;
+            }
+            
+            if (top > axisy.max) {
+                top = axisy.max;
+                drawTop = false;
+            }
+
+            left = axisx.p2c(left);
+            bottom = axisy.p2c(bottom);
+            right = axisx.p2c(right);
+            top = axisy.p2c(top);
+            
+            // fill the bar
+            if (fillStyleCallback) {
+                c.beginPath();
+                c.moveTo(left, bottom);
+                c.lineTo(left, top);
+                c.lineTo(right, top);
+                c.lineTo(right, bottom);
+                c.fillStyle = fillStyleCallback(bottom, top);
+                c.fill();
+            }
+
+            // draw outline
+            if (drawLeft || drawRight || drawTop || drawBottom) {
+                c.beginPath();
+
+                // FIXME: inline moveTo is buggy with excanvas
+                c.moveTo(left, bottom + offset);
+                if (drawLeft)
+                    c.lineTo(left, top + offset);
+                else
+                    c.moveTo(left, top + offset);
+                if (drawTop)
+                    c.lineTo(right, top + offset);
+                else
+                    c.moveTo(right, top + offset);
+                if (drawRight)
+                    c.lineTo(right, bottom + offset);
+                else
+                    c.moveTo(right, bottom + offset);
+                if (drawBottom)
+                    c.lineTo(left, bottom + offset);
+                else
+                    c.moveTo(left, bottom + offset);
+                c.stroke();
+            }
+        }
+        
+        function drawSeriesBars(series) {
+            function plotBars(datapoints, barLeft, barRight, offset, fillStyleCallback, axisx, axisy) {
+                var points = datapoints.points, ps = datapoints.pointsize;
+                
+                for (var i = 0; i < points.length; i += ps) {
+                    if (points[i] == null)
+                        continue;
+                    drawBar(points[i], points[i + 1], points[i + 2], barLeft, barRight, offset, fillStyleCallback, axisx, axisy, ctx, series.bars.horizontal);
+                }
+            }
+
+            ctx.save();
+            ctx.translate(plotOffset.left, plotOffset.top);
+
+            // FIXME: figure out a way to add shadows (for instance along the right edge)
+            ctx.lineWidth = series.bars.lineWidth;
+            ctx.strokeStyle = series.color;
+            var barLeft = series.bars.align == "left" ? 0 : -series.bars.barWidth/2;
+            var fillStyleCallback = series.bars.fill ? function (bottom, top) { return getFillStyle(series.bars, series.color, bottom, top); } : null;
+            plotBars(series.datapoints, barLeft, barLeft + series.bars.barWidth, 0, fillStyleCallback, series.xaxis, series.yaxis);
+            ctx.restore();
+        }
+
+        function getFillStyle(filloptions, seriesColor, bottom, top) {
+            var fill = filloptions.fill;
+            if (!fill)
+                return null;
+
+            if (filloptions.fillColor)
+                return getColorOrGradient(filloptions.fillColor, bottom, top, seriesColor);
+            
+            var c = $.color.parse(seriesColor);
+            c.a = typeof fill == "number" ? fill : 0.4;
+            c.normalize();
+            return c.toString();
+        }
+        
+        function insertLegend() {
+            placeholder.find(".legend").remove();
+
+            if (!options.legend.show)
+                return;
+            
+            var fragments = [], rowStarted = false,
+                lf = options.legend.labelFormatter, s, label;
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+                label = s.label;
+                if (!label)
+                    continue;
+                
+                if (i % options.legend.noColumns == 0) {
+                    if (rowStarted)
+                        fragments.push('</tr>');
+                    fragments.push('<tr>');
+                    rowStarted = true;
+                }
+
+                if (lf)
+                    label = lf(label, s);
+                
+                fragments.push(
+                    '<td class="legendColorBox"><div style="border:1px solid ' + options.legend.labelBoxBorderColor + ';padding:1px"><div style="width:4px;height:0;border:5px solid ' + s.color + ';overflow:hidden"></div></div></td>' +
+                    '<td class="legendLabel">' + label + '</td>');
+            }
+            if (rowStarted)
+                fragments.push('</tr>');
+            
+            if (fragments.length == 0)
+                return;
+
+            var table = '<table style="font-size:smaller;color:' + options.grid.color + '">' + fragments.join("") + '</table>';
+            if (options.legend.container != null)
+                $(options.legend.container).html(table);
+            else {
+                var pos = "",
+                    p = options.legend.position,
+                    m = options.legend.margin;
+                if (m[0] == null)
+                    m = [m, m];
+                if (p.charAt(0) == "n")
+                    pos += 'top:' + (m[1] + plotOffset.top) + 'px;';
+                else if (p.charAt(0) == "s")
+                    pos += 'bottom:' + (m[1] + plotOffset.bottom) + 'px;';
+                if (p.charAt(1) == "e")
+                    pos += 'right:' + (m[0] + plotOffset.right) + 'px;';
+                else if (p.charAt(1) == "w")
+                    pos += 'left:' + (m[0] + plotOffset.left) + 'px;';
+                var legend = $('<div class="legend">' + table.replace('style="', 'style="position:absolute;' + pos +';') + '</div>').appendTo(placeholder);
+                if (options.legend.backgroundOpacity != 0.0) {
+                    // put in the transparent background
+                    // separately to avoid blended labels and
+                    // label boxes
+                    var c = options.legend.backgroundColor;
+                    if (c == null) {
+                        c = options.grid.backgroundColor;
+                        if (c && typeof c == "string")
+                            c = $.color.parse(c);
+                        else
+                            c = $.color.extract(legend, 'background-color');
+                        c.a = 1;
+                        c = c.toString();
+                    }
+                    var div = legend.children();
+                    $('<div style="position:absolute;width:' + div.width() + 'px;height:' + div.height() + 'px;' + pos +'background-color:' + c + ';"> </div>').prependTo(legend).css('opacity', options.legend.backgroundOpacity);
+                }
+            }
+        }
+
+
+        // interactive features
+        
+        var highlights = [],
+            redrawTimeout = null;
+        
+        // returns the data item the mouse is over, or null if none is found
+        function findNearbyItem(mouseX, mouseY, seriesFilter) {
+            var maxDistance = options.grid.mouseActiveRadius,
+                smallestDistance = maxDistance * maxDistance + 1,
+                item = null, foundPoint = false, i, j;
+
+            for (i = 0; i < series.length; ++i) {
+                if (!seriesFilter(series[i]))
+                    continue;
+                
+                var s = series[i],
+                    axisx = s.xaxis,
+                    axisy = s.yaxis,
+                    points = s.datapoints.points,
+                    ps = s.datapoints.pointsize,
+                    mx = axisx.c2p(mouseX), // precompute some stuff to make the loop faster
+                    my = axisy.c2p(mouseY),
+                    maxx = maxDistance / axisx.scale,
+                    maxy = maxDistance / axisy.scale;
+
+                if (s.lines.show || s.points.show) {
+                    for (j = 0; j < points.length; j += ps) {
+                        var x = points[j], y = points[j + 1];
+                        if (x == null)
+                            continue;
+                        
+                        // For points and lines, the cursor must be within a
+                        // certain distance to the data point
+                        if (x - mx > maxx || x - mx < -maxx ||
+                            y - my > maxy || y - my < -maxy)
+                            continue;
+
+                        // We have to calculate distances in pixels, not in
+                        // data units, because the scales of the axes may be different
+                        var dx = Math.abs(axisx.p2c(x) - mouseX),
+                            dy = Math.abs(axisy.p2c(y) - mouseY),
+                            dist = dx * dx + dy * dy; // we save the sqrt
+
+                        // use <= to ensure last point takes precedence
+                        // (last generally means on top of)
+                        if (dist <= smallestDistance) {
+                            smallestDistance = dist;
+                            item = [i, j / ps];
+                        }
+                    }
+                }
+                    
+                if (s.bars.show && !item) { // no other point can be nearby
+                    var barLeft = s.bars.align == "left" ? 0 : -s.bars.barWidth/2,
+                        barRight = barLeft + s.bars.barWidth;
+                    
+                    for (j = 0; j < points.length; j += ps) {
+                        var x = points[j], y = points[j + 1], b = points[j + 2];
+                        if (x == null)
+                            continue;
+  
+                        // for a bar graph, the cursor must be inside the bar
+                        if (series[i].bars.horizontal ? 
+                            (mx <= Math.max(b, x) && mx >= Math.min(b, x) && 
+                             my >= y + barLeft && my <= y + barRight) :
+                            (mx >= x + barLeft && mx <= x + barRight &&
+                             my >= Math.min(b, y) && my <= Math.max(b, y)))
+                                item = [i, j / ps];
+                    }
+                }
+            }
+
+            if (item) {
+                i = item[0];
+                j = item[1];
+                ps = series[i].datapoints.pointsize;
+                
+                return { datapoint: series[i].datapoints.points.slice(j * ps, (j + 1) * ps),
+                         dataIndex: j,
+                         series: series[i],
+                         seriesIndex: i };
+            }
+            
+            return null;
+        }
+
+        function onMouseMove(e) {
+            if (options.grid.hoverable)
+                triggerClickHoverEvent("plothover", e,
+                                       function (s) { return s["hoverable"] != false; });
+        }
+        
+        function onClick(e) {
+            triggerClickHoverEvent("plotclick", e,
+                                   function (s) { return s["clickable"] != false; });
+        }
+
+        // trigger click or hover event (they send the same parameters
+        // so we share their code)
+        function triggerClickHoverEvent(eventname, event, seriesFilter) {
+            var offset = eventHolder.offset(),
+                pos = { pageX: event.pageX, pageY: event.pageY },
+                canvasX = event.pageX - offset.left - plotOffset.left,
+                canvasY = event.pageY - offset.top - plotOffset.top;
+
+            if (axes.xaxis.used)
+                pos.x = axes.xaxis.c2p(canvasX);
+            if (axes.yaxis.used)
+                pos.y = axes.yaxis.c2p(canvasY);
+            if (axes.x2axis.used)
+                pos.x2 = axes.x2axis.c2p(canvasX);
+            if (axes.y2axis.used)
+                pos.y2 = axes.y2axis.c2p(canvasY);
+
+            var item = findNearbyItem(canvasX, canvasY, seriesFilter);
+
+            if (item) {
+                // fill in mouse pos for any listeners out there
+                item.pageX = parseInt(item.series.xaxis.p2c(item.datapoint[0]) + offset.left + plotOffset.left);
+                item.pageY = parseInt(item.series.yaxis.p2c(item.datapoint[1]) + offset.top + plotOffset.top);
+            }
+
+            if (options.grid.autoHighlight) {
+                // clear auto-highlights
+                for (var i = 0; i < highlights.length; ++i) {
+                    var h = highlights[i];
+                    if (h.auto == eventname &&
+                        !(item && h.series == item.series && h.point == item.datapoint))
+                        unhighlight(h.series, h.point);
+                }
+                
+                if (item)
+                    highlight(item.series, item.datapoint, eventname);
+            }
+            
+            placeholder.trigger(eventname, [ pos, item ]);
+        }
+
+        function triggerRedrawOverlay() {
+            if (!redrawTimeout)
+                redrawTimeout = setTimeout(drawOverlay, 30);
+        }
+
+        function drawOverlay() {
+            redrawTimeout = null;
+
+            // draw highlights
+            octx.save();
+            octx.clearRect(0, 0, canvasWidth, canvasHeight);
+            octx.translate(plotOffset.left, plotOffset.top);
+            
+            var i, hi;
+            for (i = 0; i < highlights.length; ++i) {
+                hi = highlights[i];
+
+                if (hi.series.bars.show)
+                    drawBarHighlight(hi.series, hi.point);
+                else
+                    drawPointHighlight(hi.series, hi.point);
+            }
+            octx.restore();
+            
+            executeHooks(hooks.drawOverlay, [octx]);
+        }
+        
+        function highlight(s, point, auto) {
+            if (typeof s == "number")
+                s = series[s];
+
+            if (typeof point == "number")
+                point = s.data[point];
+
+            var i = indexOfHighlight(s, point);
+            if (i == -1) {
+                highlights.push({ series: s, point: point, auto: auto });
+
+                triggerRedrawOverlay();
+            }
+            else if (!auto)
+                highlights[i].auto = false;
+        }
+            
+        function unhighlight(s, point) {
+            if (s == null && point == null) {
+                highlights = [];
+                triggerRedrawOverlay();
+            }
+            
+            if (typeof s == "number")
+                s = series[s];
+
+            if (typeof point == "number")
+                point = s.data[point];
+
+            var i = indexOfHighlight(s, point);
+            if (i != -1) {
+                highlights.splice(i, 1);
+
+                triggerRedrawOverlay();
+            }
+        }
+        
+        function indexOfHighlight(s, p) {
+            for (var i = 0; i < highlights.length; ++i) {
+                var h = highlights[i];
+                if (h.series == s && h.point[0] == p[0]
+                    && h.point[1] == p[1])
+                    return i;
+            }
+            return -1;
+        }
+        
+        function drawPointHighlight(series, point) {
+            var x = point[0], y = point[1],
+                axisx = series.xaxis, axisy = series.yaxis;
+            
+            if (x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max)
+                return;
+            
+            var pointRadius = series.points.radius + series.points.lineWidth / 2;
+            octx.lineWidth = pointRadius;
+            octx.strokeStyle = $.color.parse(series.color).scale('a', 0.5).toString();
+            var radius = 1.5 * pointRadius;
+            octx.beginPath();
+            octx.arc(axisx.p2c(x), axisy.p2c(y), radius, 0, 2 * Math.PI, false);
+            octx.stroke();
+        }
+
+        function drawBarHighlight(series, point) {
+            octx.lineWidth = series.bars.lineWidth;
+            octx.strokeStyle = $.color.parse(series.color).scale('a', 0.5).toString();
+            var fillStyle = $.color.parse(series.color).scale('a', 0.5).toString();
+            var barLeft = series.bars.align == "left" ? 0 : -series.bars.barWidth/2;
+            drawBar(point[0], point[1], point[2] || 0, barLeft, barLeft + series.bars.barWidth,
+                    0, function () { return fillStyle; }, series.xaxis, series.yaxis, octx, series.bars.horizontal);
+        }
+
+        function getColorOrGradient(spec, bottom, top, defaultColor) {
+            if (typeof spec == "string")
+                return spec;
+            else {
+                // assume this is a gradient spec; IE currently only
+                // supports a simple vertical gradient properly, so that's
+                // what we support too
+                var gradient = ctx.createLinearGradient(0, top, 0, bottom);
+                
+                for (var i = 0, l = spec.colors.length; i < l; ++i) {
+                    var c = spec.colors[i];
+                    if (typeof c != "string") {
+                        c = $.color.parse(defaultColor).scale('rgb', c.brightness);
+                        c.a *= c.opacity;
+                        c = c.toString();
+                    }
+                    gradient.addColorStop(i / (l - 1), c);
+                }
+                
+                return gradient;
+            }
+        }
+    }
+
+    $.plot = function(placeholder, data, options) {
+        var plot = new Plot($(placeholder), data, options, $.plot.plugins);
+        /*var t0 = new Date();
+        var t1 = new Date();
+        var tstr = "time used (msecs): " + (t1.getTime() - t0.getTime())
+        if (window.console)
+            console.log(tstr);
+        else
+            alert(tstr);*/
+        return plot;
+    };
+
+    $.plot.plugins = [];
+
+    // returns a string with the date d formatted according to fmt
+    $.plot.formatDate = function(d, fmt, monthNames) {
+        var leftPad = function(n) {
+            n = "" + n;
+            return n.length == 1 ? "0" + n : n;
+        };
+        
+        var r = [];
+        var escape = false;
+        var hours = d.getUTCHours();
+        var isAM = hours < 12;
+        if (monthNames == null)
+            monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+        if (fmt.search(/%p|%P/) != -1) {
+            if (hours > 12) {
+                hours = hours - 12;
+            } else if (hours == 0) {
+                hours = 12;
+            }
+        }
+        for (var i = 0; i < fmt.length; ++i) {
+            var c = fmt.charAt(i);
+            
+            if (escape) {
+                switch (c) {
+                case 'h': c = "" + hours; break;
+                case 'H': c = leftPad(hours); break;
+                case 'M': c = leftPad(d.getUTCMinutes()); break;
+                case 'S': c = leftPad(d.getUTCSeconds()); break;
+                case 'd': c = "" + d.getUTCDate(); break;
+                case 'm': c = "" + (d.getUTCMonth() + 1); break;
+                case 'y': c = "" + d.getUTCFullYear(); break;
+                case 'b': c = "" + monthNames[d.getUTCMonth()]; break;
+                case 'p': c = (isAM) ? ("" + "am") : ("" + "pm"); break;
+                case 'P': c = (isAM) ? ("" + "AM") : ("" + "PM"); break;
+                }
+                r.push(c);
+                escape = false;
+            }
+            else {
+                if (c == "%")
+                    escape = true;
+                else
+                    r.push(c);
+            }
+        }
+        return r.join("");
+    };
+    
+    // round to nearby lower multiple of base
+    function floorInBase(n, base) {
+        return base * Math.floor(n / base);
+    }
+    
+})(jQuery);
diff --git a/web/js/jquery.markitup.js b/web/js/jquery.markitup.js
new file mode 100755 (executable)
index 0000000..ee8f40f
--- /dev/null
@@ -0,0 +1,559 @@
+// ----------------------------------------------------------------------------
+// markItUp! Universal MarkUp Engine, JQuery plugin
+// v 1.1.7
+// Dual licensed under the MIT and GPL licenses.
+// ----------------------------------------------------------------------------
+// Copyright (C) 2007-2010 Jay Salvat
+// http://markitup.jaysalvat.com/
+// ----------------------------------------------------------------------------
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+// 
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+// 
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+// ----------------------------------------------------------------------------
+(function($) {
+       $.fn.markItUp = function(settings, extraSettings) {
+               var options, ctrlKey, shiftKey, altKey;
+               ctrlKey = shiftKey = altKey = false;
+
+               options = {     id:                                             '',
+                                       nameSpace:                              '',
+                                       root:                                   '',
+                                       previewInWindow:                '', // 'width=800, height=600, resizable=yes, scrollbars=yes'
+                                       previewAutoRefresh:             true,
+                                       previewPosition:                'after',
+                                       previewTemplatePath:    '~/templates/preview.html',
+                                       previewParserPath:              '',
+                                       previewParserVar:               'data',
+                                       resizeHandle:                   true,
+                                       beforeInsert:                   '',
+                                       afterInsert:                    '',
+                                       onEnter:                                {},
+                                       onShiftEnter:                   {},
+                                       onCtrlEnter:                    {},
+                                       onTab:                                  {},
+                                       markupSet:                      [       { /* set */ } ]
+                               };
+               $.extend(options, settings, extraSettings);
+
+               // compute markItUp! path
+               if (!options.root) {
+                       $('script').each(function(a, tag) {
+                               miuScript = $(tag).get(0).src.match(/(.*)jquery\.markitup(\.pack)?\.js$/);
+                               if (miuScript !== null) {
+                                       options.root = miuScript[1];
+                               }
+                       });
+               }
+
+               return this.each(function() {
+                       var $$, textarea, levels, scrollPosition, caretPosition, caretOffset,
+                               clicked, hash, header, footer, previewWindow, template, iFrame, abort;
+                       $$ = $(this);
+                       textarea = this;
+                       levels = [];
+                       abort = false;
+                       scrollPosition = caretPosition = 0;
+                       caretOffset = -1;
+
+                       options.previewParserPath = localize(options.previewParserPath);
+                       options.previewTemplatePath = localize(options.previewTemplatePath);
+
+                       // apply the computed path to ~/
+                       function localize(data, inText) {
+                               if (inText) {
+                                       return  data.replace(/("|')~\//g, "$1"+options.root);
+                               }
+                               return  data.replace(/^~\//, options.root);
+                       }
+
+                       // init and build editor
+                       function init() {
+                               id = ''; nameSpace = '';
+                               if (options.id) {
+                                       id = 'id="'+options.id+'"';
+                               } else if ($$.attr("id")) {
+                                       id = 'id="markItUp'+($$.attr("id").substr(0, 1).toUpperCase())+($$.attr("id").substr(1))+'"';
+
+                               }
+                               if (options.nameSpace) {
+                                       nameSpace = 'class="'+options.nameSpace+'"';
+                               }
+                               $$.wrap('<div '+nameSpace+'></div>');
+                               $$.wrap('<div '+id+' class="markItUp"></div>');
+                               $$.wrap('<div class="markItUpContainer"></div>');
+                               $$.addClass("markItUpEditor");
+
+                               // add the header before the textarea
+                               header = $('<div class="markItUpHeader"></div>').insertBefore($$);
+                               $(dropMenus(options.markupSet)).appendTo(header);
+
+                               // add the footer after the textarea
+                               footer = $('<div class="markItUpFooter"></div>').insertAfter($$);
+
+                               // add the resize handle after textarea
+                               if (options.resizeHandle === true && $.browser.safari !== true) {
+                                       resizeHandle = $('<div class="markItUpResizeHandle"></div>')
+                                               .insertAfter($$)
+                                               .bind("mousedown", function(e) {
+                                                       var h = $$.height(), y = e.clientY, mouseMove, mouseUp;
+                                                       mouseMove = function(e) {
+                                                               $$.css("height", Math.max(20, e.clientY+h-y)+"px");
+                                                               return false;
+                                                       };
+                                                       mouseUp = function(e) {
+                                                               $("html").unbind("mousemove", mouseMove).unbind("mouseup", mouseUp);
+                                                               return false;
+                                                       };
+                                                       $("html").bind("mousemove", mouseMove).bind("mouseup", mouseUp);
+                                       });
+                                       footer.append(resizeHandle);
+                               }
+
+                               // listen key events
+                               $$.keydown(keyPressed).keyup(keyPressed);
+                               
+                               // bind an event to catch external calls
+                               $$.bind("insertion", function(e, settings) {
+                                       if (settings.target !== false) {
+                                               get();
+                                       }
+                                       if (textarea === $.markItUp.focused) {
+                                               markup(settings);
+                                       }
+                               });
+
+                               // remember the last focus
+                               $$.focus(function() {
+                                       $.markItUp.focused = this;
+                               });
+                       }
+
+                       // recursively build header with dropMenus from markupset
+                       function dropMenus(markupSet) {
+                               var ul = $('<ul></ul>'), i = 0;
+                               $('li:hover > ul', ul).css('display', 'block');
+                               $.each(markupSet, function() {
+                                       var button = this, t = '', title, li, j;
+                                       title = (button.key) ? (button.name||'')+' [Ctrl+'+button.key+']' : (button.name||'');
+                                       key   = (button.key) ? 'accesskey="'+button.key+'"' : '';
+                                       if (button.separator) {
+                                               li = $('<li class="markItUpSeparator">'+(button.separator||'')+'</li>').appendTo(ul);
+                                       } else {
+                                               i++;
+                                               for (j = levels.length -1; j >= 0; j--) {
+                                                       t += levels[j]+"-";
+                                               }
+                                               li = $('<li class="markItUpButton markItUpButton'+t+(i)+' '+(button.className||'')+'"><a href="" '+key+' title="'+title+'">'+(button.name||'')+'</a></li>')
+                                               .bind("contextmenu", function() { // prevent contextmenu on mac and allow ctrl+click
+                                                       return false;
+                                               }).click(function() {
+                                                       return false;
+                                               }).mousedown(function() {
+                                                       if (button.call) {
+                                                               eval(button.call)();
+                                                       }
+                                                       setTimeout(function() { markup(button) },1);
+                                                       return false;
+                                               }).hover(function() {
+                                                               $('> ul', this).show();
+                                                               $(document).one('click', function() { // close dropmenu if click outside
+                                                                               $('ul ul', header).hide();
+                                                                       }
+                                                               );
+                                                       }, function() {
+                                                               $('> ul', this).hide();
+                                                       }
+                                               ).appendTo(ul);
+                                               if (button.dropMenu) {
+                                                       levels.push(i);
+                                                       $(li).addClass('markItUpDropMenu').append(dropMenus(button.dropMenu));
+                                               }
+                                       }
+                               }); 
+                               levels.pop();
+                               return ul;
+                       }
+
+                       // markItUp! markups
+                       function magicMarkups(string) {
+                               if (string) {
+                                       string = string.toString();
+                                       string = string.replace(/\(\!\(([\s\S]*?)\)\!\)/g,
+                                               function(x, a) {
+                                                       var b = a.split('|!|');
+                                                       if (altKey === true) {
+                                                               return (b[1] !== undefined) ? b[1] : b[0];
+                                                       } else {
+                                                               return (b[1] === undefined) ? "" : b[0];
+                                                       }
+                                               }
+                                       );
+                                       // [![prompt]!], [![prompt:!:value]!]
+                                       string = string.replace(/\[\!\[([\s\S]*?)\]\!\]/g,
+                                               function(x, a) {
+                                                       var b = a.split(':!:');
+                                                       if (abort === true) {
+                                                               return false;
+                                                       }
+                                                       value = prompt(b[0], (b[1]) ? b[1] : '');
+                                                       if (value === null) {
+                                                               abort = true;
+                                                       }
+                                                       return value;
+                                               }
+                                       );
+                                       return string;
+                               }
+                               return "";
+                       }
+
+                       // prepare action
+                       function prepare(action) {
+                               if ($.isFunction(action)) {
+                                       action = action(hash);
+                               }
+                               return magicMarkups(action);
+                       }
+
+                       // build block to insert
+                       function build(string) {
+                               openWith        = prepare(clicked.openWith);
+                               placeHolder = prepare(clicked.placeHolder);
+                               replaceWith = prepare(clicked.replaceWith);
+                               closeWith       = prepare(clicked.closeWith);
+                               if (replaceWith !== "") {
+                                       block = openWith + replaceWith + closeWith;
+                               } else if (selection === '' && placeHolder !== '') {
+                                       block = openWith + placeHolder + closeWith;
+                               } else {
+                                       block = openWith + (string||selection) + closeWith;
+                               }
+                               return {        block:block, 
+                                                       openWith:openWith, 
+                                                       replaceWith:replaceWith, 
+                                                       placeHolder:placeHolder,
+                                                       closeWith:closeWith
+                                       };
+                       }
+
+                       // define markup to insert
+                       function markup(button) {
+                               var len, j, n, i;
+                               hash = clicked = button;
+                               get();
+
+                               $.extend(hash, {        line:"", 
+                                                                       root:options.root,
+                                                                       textarea:textarea, 
+                                                                       selection:(selection||''), 
+                                                                       caretPosition:caretPosition,
+                                                                       ctrlKey:ctrlKey, 
+                                                                       shiftKey:shiftKey, 
+                                                                       altKey:altKey
+                                                               }
+                                                       );
+                               // callbacks before insertion
+                               prepare(options.beforeInsert);
+                               prepare(clicked.beforeInsert);
+                               if (ctrlKey === true && shiftKey === true) {
+                                       prepare(clicked.beforeMultiInsert);
+                               }                       
+                               $.extend(hash, { line:1 });
+                               
+                               if (ctrlKey === true && shiftKey === true) {
+                                       lines = selection.split(/\r?\n/);
+                                       for (j = 0, n = lines.length, i = 0; i < n; i++) {
+                                               if ($.trim(lines[i]) !== '') {
+                                                       $.extend(hash, { line:++j, selection:lines[i] } );
+                                                       lines[i] = build(lines[i]).block;
+                                               } else {
+                                                       lines[i] = "";
+                                               }
+                                       }
+                                       string = { block:lines.join('\n')};
+                                       start = caretPosition;
+                                       len = string.block.length + (($.browser.opera) ? n : 0);
+                               } else if (ctrlKey === true) {
+                                       string = build(selection);
+                                       start = caretPosition + string.openWith.length;
+                                       len = string.block.length - string.openWith.length - string.closeWith.length;
+                                       len -= fixIeBug(string.block);
+                               } else if (shiftKey === true) {
+                                       string = build(selection);
+                                       start = caretPosition;
+                                       len = string.block.length;
+                                       len -= fixIeBug(string.block);
+                               } else {
+                                       string = build(selection);
+                                       start = caretPosition + string.block.length ;
+                                       len = 0;
+                                       start -= fixIeBug(string.block);
+                               }
+                               if ((selection === '' && string.replaceWith === '')) {
+                                       caretOffset += fixOperaBug(string.block);
+                                       
+                                       start = caretPosition + string.openWith.length;
+                                       len = string.block.length - string.openWith.length - string.closeWith.length;
+
+                                       caretOffset = $$.val().substring(caretPosition,  $$.val().length).length;
+                                       caretOffset -= fixOperaBug($$.val().substring(0, caretPosition));
+                               }
+                               $.extend(hash, { caretPosition:caretPosition, scrollPosition:scrollPosition } );
+
+                               if (string.block !== selection && abort === false) {
+                                       insert(string.block);
+                                       set(start, len);
+                               } else {
+                                       caretOffset = -1;
+                               }
+                               get();
+
+                               $.extend(hash, { line:'', selection:selection });
+
+                               // callbacks after insertion
+                               if (ctrlKey === true && shiftKey === true) {
+                                       prepare(clicked.afterMultiInsert);
+                               }
+                               prepare(clicked.afterInsert);
+                               prepare(options.afterInsert);
+
+                               // refresh preview if opened
+                               if (previewWindow && options.previewAutoRefresh) {
+                                       refreshPreview(); 
+                               }
+                                                                                                                                                                                                       
+                               // reinit keyevent
+                               shiftKey = altKey = ctrlKey = abort = false;
+                       }
+
+                       // Substract linefeed in Opera
+                       function fixOperaBug(string) {
+                               if ($.browser.opera) {
+                                       return string.length - string.replace(/\n*/g, '').length;
+                               }
+                               return 0;
+                       }
+                       // Substract linefeed in IE
+                       function fixIeBug(string) {
+                               if ($.browser.msie) {
+                                       return string.length - string.replace(/\r*/g, '').length;
+                               }
+                               return 0;
+                       }
+                               
+                       // add markup
+                       function insert(block) {        
+                               if (document.selection) {
+                                       var newSelection = document.selection.createRange();
+                                       newSelection.text = block;
+                               } else {
+                                       $$.val($$.val().substring(0, caretPosition)     + block + $$.val().substring(caretPosition + selection.length, $$.val().length));
+                               }
+                       }
+
+                       // set a selection
+                       function set(start, len) {
+                               if (textarea.createTextRange){
+                                       // quick fix to make it work on Opera 9.5
+                                       if ($.browser.opera && $.browser.version >= 9.5 && len == 0) {
+                                               return false;
+                                       }
+                                       range = textarea.createTextRange();
+                                       range.collapse(true);
+                                       range.moveStart('character', start); 
+                                       range.moveEnd('character', len); 
+                                       range.select();
+                               } else if (textarea.setSelectionRange ){
+                                       textarea.setSelectionRange(start, start + len);
+                               }
+                               textarea.scrollTop = scrollPosition;
+                               textarea.focus();
+                       }
+
+                       // get the selection
+                       function get() {
+                               textarea.focus();
+
+                               scrollPosition = textarea.scrollTop;
+                               if (document.selection) {
+                                       selection = document.selection.createRange().text;
+                                       if ($.browser.msie) { // ie
+                                               var range = document.selection.createRange(), rangeCopy = range.duplicate();
+                                               rangeCopy.moveToElementText(textarea);
+                                               caretPosition = -1;
+                                               while(rangeCopy.inRange(range)) { // fix most of the ie bugs with linefeeds...
+                                                       rangeCopy.moveStart('character');
+                                                       caretPosition ++;
+                                               }
+                                       } else { // opera
+                                               caretPosition = textarea.selectionStart;
+                                       }
+                               } else { // gecko & webkit
+                                       caretPosition = textarea.selectionStart;
+                                       selection = $$.val().substring(caretPosition, textarea.selectionEnd);
+                               } 
+                               return selection;
+                       }
+
+                       // open preview window
+                       function preview() {
+                               if (!previewWindow || previewWindow.closed) {
+                                       if (options.previewInWindow) {
+                                               previewWindow = window.open('', 'preview', options.previewInWindow);
+                                       } else {
+                                               iFrame = $('<iframe class="markItUpPreviewFrame"></iframe>');
+                                               if (options.previewPosition == 'after') {
+                                                       iFrame.insertAfter(footer);
+                                               } else {
+                                                       iFrame.insertBefore(header);
+                                               }       
+                                               previewWindow = iFrame[iFrame.length - 1].contentWindow || frame[iFrame.length - 1];
+                                       }
+                               } else if (altKey === true) {
+                                       // Thx Stephen M. Redd for the IE8 fix
+                                       if (iFrame) {
+                                               iFrame.remove();
+                                       } else {
+                                               previewWindow.close();
+                                       }
+                                       previewWindow = iFrame = false;
+                               }
+                               if (!options.previewAutoRefresh) {
+                                       refreshPreview(); 
+                               }
+                       }
+
+                       // refresh Preview window
+                       function refreshPreview() {
+                               renderPreview();
+                       }
+
+                       function renderPreview() {              
+                               var phtml;
+                               if (options.previewParserPath !== '') {
+                                       $.ajax( {
+                                               type: 'POST',
+                                               url: options.previewParserPath,
+                                               data: options.previewParserVar+'='+encodeURIComponent($$.val()),
+                                               success: function(data) {
+                                                       writeInPreview( localize(data, 1) ); 
+                                               }
+                                       } );
+                               } else {
+                                       if (!template) {
+                                               $.ajax( {
+                                                       url: options.previewTemplatePath,
+                                                       success: function(data) {
+                                                               writeInPreview( localize(data, 1).replace(/<!-- content -->/g, $$.val()) );
+                                                       }
+                                               } );
+                                       }
+                               }
+                               return false;
+                       }
+                       
+                       function writeInPreview(data) {
+                               if (previewWindow.document) {                   
+                                       try {
+                                               sp = previewWindow.document.documentElement.scrollTop
+                                       } catch(e) {
+                                               sp = 0;
+                                       }       
+                                       previewWindow.document.open();
+                                       previewWindow.document.write(data);
+                                       previewWindow.document.close();
+                                       previewWindow.document.documentElement.scrollTop = sp;
+                               }
+                               if (options.previewInWindow) {
+                                       previewWindow.focus();
+                               }
+                       }
+                       
+                       // set keys pressed
+                       function keyPressed(e) { 
+                               shiftKey = e.shiftKey;
+                               altKey = e.altKey;
+                               ctrlKey = (!(e.altKey && e.ctrlKey)) ? e.ctrlKey : false;
+
+                               if (e.type === 'keydown') {
+                                       if (ctrlKey === true) {
+                                               li = $("a[accesskey="+String.fromCharCode(e.keyCode)+"]", header).parent('li');
+                                               if (li.length !== 0) {
+                                                       ctrlKey = false;
+                                                       setTimeout(function() {
+                                                               li.triggerHandler('mousedown');
+                                                       },1);
+                                                       return false;
+                                               }
+                                       }
+                                       if (e.keyCode === 13 || e.keyCode === 10) { // Enter key
+                                               if (ctrlKey === true) {  // Enter + Ctrl
+                                                       ctrlKey = false;
+                                                       markup(options.onCtrlEnter);
+                                                       return options.onCtrlEnter.keepDefault;
+                                               } else if (shiftKey === true) { // Enter + Shift
+                                                       shiftKey = false;
+                                                       markup(options.onShiftEnter);
+                                                       return options.onShiftEnter.keepDefault;
+                                               } else { // only Enter
+                                                       markup(options.onEnter);
+                                                       return options.onEnter.keepDefault;
+                                               }
+                                       }
+                                       if (e.keyCode === 9) { // Tab key
+                                               if (shiftKey == true || ctrlKey == true || altKey == true) { // Thx Dr Floob.
+                                                       return false; 
+                                               }
+                                               if (caretOffset !== -1) {
+                                                       get();
+                                                       caretOffset = $$.val().length - caretOffset;
+                                                       set(caretOffset, 0);
+                                                       caretOffset = -1;
+                                                       return false;
+                                               } else {
+                                                       markup(options.onTab);
+                                                       return options.onTab.keepDefault;
+                                               }
+                                       }
+                               }
+                       }
+
+                       init();
+               });
+       };
+
+       $.fn.markItUpRemove = function() {
+               return this.each(function() {
+                               var $$ = $(this).unbind().removeClass('markItUpEditor');
+                               $$.parent('div').parent('div.markItUp').parent('div').replaceWith($$);
+                       }
+               );
+       };
+
+       $.markItUp = function(settings) {
+               var options = { target:false };
+               $.extend(options, settings);
+               if (options.target) {
+                       return $(options.target).each(function() {
+                               $(this).focus();
+                               $(this).trigger('insertion', [options]);
+                       });
+               } else {
+                       $('textarea').trigger('insertion', [options]);
+               }
+       };
+})(jQuery);
diff --git a/web/js/jquery.metadata.js b/web/js/jquery.metadata.js
new file mode 100755 (executable)
index 0000000..6a984db
--- /dev/null
@@ -0,0 +1,122 @@
+/*
+ * Metadata - jQuery plugin for parsing metadata from elements
+ *
+ * Copyright (c) 2006 John Resig, Yehuda Katz, J�örn Zaefferer, Paul McLanahan
+ *
+ * Dual licensed under the MIT and GPL licenses:
+ *   http://www.opensource.org/licenses/mit-license.php
+ *   http://www.gnu.org/licenses/gpl.html
+ *
+ * Revision: $Id$
+ *
+ */
+
+/**
+ * Sets the type of metadata to use. Metadata is encoded in JSON, and each property
+ * in the JSON will become a property of the element itself.
+ *
+ * There are three supported types of metadata storage:
+ *
+ *   attr:  Inside an attribute. The name parameter indicates *which* attribute.
+ *          
+ *   class: Inside the class attribute, wrapped in curly braces: { }
+ *   
+ *   elem:  Inside a child element (e.g. a script tag). The
+ *          name parameter indicates *which* element.
+ *          
+ * The metadata for an element is loaded the first time the element is accessed via jQuery.
+ *
+ * As a result, you can define the metadata type, use $(expr) to load the metadata into the elements
+ * matched by expr, then redefine the metadata type and run another $(expr) for other elements.
+ * 
+ * @name $.metadata.setType
+ *
+ * @example <p id="one" class="some_class {item_id: 1, item_label: 'Label'}">This is a p</p>
+ * @before $.metadata.setType("class")
+ * @after $("#one").metadata().item_id == 1; $("#one").metadata().item_label == "Label"
+ * @desc Reads metadata from the class attribute
+ * 
+ * @example <p id="one" class="some_class" data="{item_id: 1, item_label: 'Label'}">This is a p</p>
+ * @before $.metadata.setType("attr", "data")
+ * @after $("#one").metadata().item_id == 1; $("#one").metadata().item_label == "Label"
+ * @desc Reads metadata from a "data" attribute
+ * 
+ * @example <p id="one" class="some_class"><script>{item_id: 1, item_label: 'Label'}</script>This is a p</p>
+ * @before $.metadata.setType("elem", "script")
+ * @after $("#one").metadata().item_id == 1; $("#one").metadata().item_label == "Label"
+ * @desc Reads metadata from a nested script element
+ * 
+ * @param String type The encoding type
+ * @param String name The name of the attribute to be used to get metadata (optional)
+ * @cat Plugins/Metadata
+ * @descr Sets the type of encoding to be used when loading metadata for the first time
+ * @type undefined
+ * @see metadata()
+ */
+
+(function($) {
+
+$.extend({
+       metadata : {
+               defaults : {
+                       type: 'class',
+                       name: 'metadata',
+                       cre: /({.*})/,
+                       single: 'metadata'
+               },
+               setType: function( type, name ){
+                       this.defaults.type = type;
+                       this.defaults.name = name;
+               },
+               get: function( elem, opts ){
+                       var settings = $.extend({},this.defaults,opts);
+                       // check for empty string in single property
+                       if ( !settings.single.length ) settings.single = 'metadata';
+                       
+                       var data = $.data(elem, settings.single);
+                       // returned cached data if it already exists
+                       if ( data ) return data;
+                       
+                       data = "{}";
+                       
+                       if ( settings.type == "class" ) {
+                               var m = settings.cre.exec( elem.className );
+                               if ( m )
+                                       data = m[1];
+                       } else if ( settings.type == "elem" ) {
+                               if( !elem.getElementsByTagName )
+                                       return undefined;
+                               var e = elem.getElementsByTagName(settings.name);
+                               if ( e.length )
+                                       data = $.trim(e[0].innerHTML);
+                       } else if ( elem.getAttribute != undefined ) {
+                               var attr = elem.getAttribute( settings.name );
+                               if ( attr )
+                                       data = attr;
+                       }
+                       
+                       if ( data.indexOf( '{' ) <0 )
+                       data = "{" + data + "}";
+                       
+                       data = eval("(" + data + ")");
+                       
+                       $.data( elem, settings.single, data );
+                       return data;
+               }
+       }
+});
+
+/**
+ * Returns the metadata object for the first member of the jQuery object.
+ *
+ * @name metadata
+ * @descr Returns element's metadata object
+ * @param Object opts An object contianing settings to override the defaults
+ * @type jQuery
+ * @cat Plugins/Metadata
+ */
+$.fn.metadata = function( opts ){
+       return $.metadata.get( this[0], opts );
+};
+
+})(jQuery);
\ No newline at end of file
diff --git a/web/js/jquery.tablesorter.js b/web/js/jquery.tablesorter.js
new file mode 100644 (file)
index 0000000..f781334
--- /dev/null
@@ -0,0 +1,852 @@
+/*
+ * 
+ * TableSorter 2.0 - Client-side table sorting with ease!
+ * Version 2.0.3
+ * @requires jQuery v1.2.3
+ * 
+ * Copyright (c) 2007 Christian Bach
+ * Examples and docs at: http://tablesorter.com
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ * 
+ */
+/**
+ *
+ * @description Create a sortable table with multi-column sorting capabilitys
+ * 
+ * @example $('table').tablesorter();
+ * @desc Create a simple tablesorter interface.
+ *
+ * @example $('table').tablesorter({ sortList:[[0,0],[1,0]] });
+ * @desc Create a tablesorter interface and sort on the first and secound column in ascending order.
+ * 
+ * @example $('table').tablesorter({ headers: { 0: { sorter: false}, 1: {sorter: false} } });
+ * @desc Create a tablesorter interface and disableing the first and secound column headers.
+ * 
+ * @example $('table').tablesorter({ 0: {sorter:"integer"}, 1: {sorter:"currency"} });
+ * @desc Create a tablesorter interface and set a column parser for the first and secound column.
+ * 
+ * 
+ * @param Object settings An object literal containing key/value pairs to provide optional settings.
+ * 
+ * @option String cssHeader (optional)                         A string of the class name to be appended to sortable tr elements in the thead of the table. 
+ *                                                                                             Default value: "header"
+ * 
+ * @option String cssAsc (optional)                    A string of the class name to be appended to sortable tr elements in the thead on a ascending sort. 
+ *                                                                                             Default value: "headerSortUp"
+ * 
+ * @option String cssDesc (optional)                   A string of the class name to be appended to sortable tr elements in the thead on a descending sort. 
+ *                                                                                             Default value: "headerSortDown"
+ * 
+ * @option String sortInitialOrder (optional)  A string of the inital sorting order can be asc or desc. 
+ *                                                                                             Default value: "asc"
+ * 
+ * @option String sortMultisortKey (optional)  A string of the multi-column sort key. 
+ *                                                                                             Default value: "shiftKey"
+ * 
+ * @option String textExtraction (optional)    A string of the text-extraction method to use. 
+ *                                                                                             For complex html structures inside td cell set this option to "complex", 
+ *                                                                                             on large tables the complex option can be slow. 
+ *                                                                                             Default value: "simple"
+ * 
+ * @option Object headers (optional)                   An array containing the forces sorting rules. 
+ *                                                                                             This option let's you specify a default sorting rule. 
+ *                                                                                             Default value: null
+ * 
+ * @option Array sortList (optional)                   An array containing the forces sorting rules. 
+ *                                                                                             This option let's you specify a default sorting rule. 
+ *                                                                                             Default value: null
+ * 
+ * @option Array sortForce (optional)                  An array containing forced sorting rules. 
+ *                                                                                             This option let's you specify a default sorting rule, which is prepended to user-selected rules.
+ *                                                                                             Default value: null
+ *  
+  * @option Array sortAppend (optional)                        An array containing forced sorting rules. 
+ *                                                                                             This option let's you specify a default sorting rule, which is appended to user-selected rules.
+ *                                                                                             Default value: null
+ * 
+ * @option Boolean widthFixed (optional)               Boolean flag indicating if tablesorter should apply fixed widths to the table columns.
+ *                                                                                             This is usefull when using the pager companion plugin.
+ *                                                                                             This options requires the dimension jquery plugin.
+ *                                                                                             Default value: false
+ *
+ * @option Boolean cancelSelection (optional)  Boolean flag indicating if tablesorter should cancel selection of the table headers text.
+ *                                                                                             Default value: true
+ *
+ * @option Boolean debug (optional)                    Boolean flag indicating if tablesorter should display debuging information usefull for development.
+ *
+ * @type jQuery
+ *
+ * @name tablesorter
+ * 
+ * @cat Plugins/Tablesorter
+ * 
+ * @author Christian Bach/christian.bach@polyester.se
+ */
+
+(function($) {
+       $.extend({
+               tablesorter: new function() {
+                       
+                       var parsers = [], widgets = [];
+                       
+                       this.defaults = {
+                               cssHeader: "header",
+                               cssAsc: "headerSortUp",
+                               cssDesc: "headerSortDown",
+                               sortInitialOrder: "asc",
+                               sortMultiSortKey: "shiftKey",
+                               sortForce: null,
+                               sortAppend: null,
+                               textExtraction: "simple",
+                               parsers: {}, 
+                               widgets: [],            
+                               widgetZebra: {css: ["even","odd"]},
+                               headers: {},
+                               widthFixed: false,
+                               cancelSelection: true,
+                               sortList: [],
+                               headerList: [],
+                               dateFormat: "us",
+                               decimal: '.',
+                               debug: false
+                       };
+                       
+                       /* debuging utils */
+                       function benchmark(s,d) {
+                               log(s + "," + (new Date().getTime() - d.getTime()) + "ms");
+                       }
+                       
+                       this.benchmark = benchmark;
+                       
+                       function log(s) {
+                               if (typeof console != "undefined" && typeof console.debug != "undefined") {
+                                       console.log(s);
+                               } else {
+                                       alert(s);
+                               }
+                       }
+                                               
+                       /* parsers utils */
+                       function buildParserCache(table,$headers) {
+                               
+                               if(table.config.debug) { var parsersDebug = ""; }
+                               
+                               var rows = table.tBodies[0].rows;
+                               
+                               if(table.tBodies[0].rows[0]) {
+
+                                       var list = [], cells = rows[0].cells, l = cells.length;
+                                       
+                                       for (var i=0;i < l; i++) {
+                                               var p = false;
+                                               
+                                               if($.metadata && ($($headers[i]).metadata() && $($headers[i]).metadata().sorter)  ) {
+                                               
+                                                       p = getParserById($($headers[i]).metadata().sorter);    
+                                               
+                                               } else if((table.config.headers[i] && table.config.headers[i].sorter)) {
+       
+                                                       p = getParserById(table.config.headers[i].sorter);
+                                               }
+                                               if(!p) {
+                                                       p = detectParserForColumn(table,cells[i]);
+                                               }
+       
+                                               if(table.config.debug) { parsersDebug += "column:" + i + " parser:" +p.id + "\n"; }
+       
+                                               list.push(p);
+                                       }
+                               }
+                               
+                               if(table.config.debug) { log(parsersDebug); }
+
+                               return list;
+                       };
+                       
+                       function detectParserForColumn(table,node) {
+                               var l = parsers.length;
+                               for(var i=1; i < l; i++) {
+                                       if(parsers[i].is($.trim(getElementText(table.config,node)),table,node)) {
+                                               return parsers[i];
+                                       }
+                               }
+                               // 0 is always the generic parser (text)
+                               return parsers[0];
+                       }
+                       
+                       function getParserById(name) {
+                               var l = parsers.length;
+                               for(var i=0; i < l; i++) {
+                                       if(parsers[i].id.toLowerCase() == name.toLowerCase()) { 
+                                               return parsers[i];
+                                       }
+                               }
+                               return false;
+                       }
+                       
+                       /* utils */
+                       function buildCache(table) {
+                               
+                               if(table.config.debug) { var cacheTime = new Date(); }
+                               
+                               
+                               var totalRows = (table.tBodies[0] && table.tBodies[0].rows.length) || 0,
+                                       totalCells = (table.tBodies[0].rows[0] && table.tBodies[0].rows[0].cells.length) || 0,
+                                       parsers = table.config.parsers, 
+                                       cache = {row: [], normalized: []};
+                               
+                                       for (var i=0;i < totalRows; ++i) {
+                                       
+                                               /** Add the table data to main data array */
+                                               var c = table.tBodies[0].rows[i], cols = [];
+                                       
+                                               cache.row.push($(c));
+                                               
+                                               for(var j=0; j < totalCells; ++j) {
+                                                       cols.push(parsers[j].format(getElementText(table.config,c.cells[j]),table,c.cells[j])); 
+                                               }
+                                                                                               
+                                               cols.push(i); // add position for rowCache
+                                               cache.normalized.push(cols);
+                                               cols = null;
+                                       };
+                               
+                               if(table.config.debug) { benchmark("Building cache for " + totalRows + " rows:", cacheTime); }
+                               
+                               return cache;
+                       };
+                       
+                       function getElementText(config,node) {
+                               
+                               if(!node) return "";
+                                                               
+                               var t = "";
+                               
+                               if(config.textExtraction == "simple") {
+                                       if(node.childNodes[0] && node.childNodes[0].hasChildNodes()) {
+                                               t = node.childNodes[0].innerHTML;
+                                       } else {
+                                               t = node.innerHTML;
+                                       }
+                               } else {
+                                       if(typeof(config.textExtraction) == "function") {
+                                               t = config.textExtraction(node);
+                                       } else { 
+                                               t = $(node).text();
+                                       }       
+                               }
+                               return t;
+                       }
+                       
+                       function appendToTable(table,cache) {
+                               
+                               if(table.config.debug) {var appendTime = new Date()}
+                               
+                               var c = cache, 
+                                       r = c.row, 
+                                       n= c.normalized, 
+                                       totalRows = n.length, 
+                                       checkCell = (n[0].length-1), 
+                                       tableBody = $(table.tBodies[0]),
+                                       rows = [];
+                               
+                               for (var i=0;i < totalRows; i++) {
+                                       rows.push(r[n[i][checkCell]]);  
+                                       if(!table.config.appender) {
+                                               
+                                               var o = r[n[i][checkCell]];
+                                               var l = o.length;
+                                               for(var j=0; j < l; j++) {
+                                                       
+                                                       tableBody[0].appendChild(o[j]);
+                                               
+                                               }
+                                               
+                                               //tableBody.append(r[n[i][checkCell]]);
+                                       }
+                               }       
+                               
+                               if(table.config.appender) {
+                               
+                                       table.config.appender(table,rows);      
+                               }
+                               
+                               rows = null;
+                               
+                               if(table.config.debug) { benchmark("Rebuilt table:", appendTime); }
+                                                               
+                               //apply table widgets
+                               applyWidget(table);
+                               
+                               // trigger sortend
+                               setTimeout(function() {
+                                       $(table).trigger("sortEnd");    
+                               },0);
+                               
+                       };
+                       
+                       function buildHeaders(table) {
+                               
+                               if(table.config.debug) { var time = new Date(); }
+                               
+                               var meta = ($.metadata) ? true : false, tableHeadersRows = [];
+                       
+                               for(var i = 0; i < table.tHead.rows.length; i++) { tableHeadersRows[i]=0; };
+                               
+                               $tableHeaders = $("thead th",table);
+               
+                               $tableHeaders.each(function(index) {
+                                                       
+                                       this.count = 0;
+                                       this.column = index;
+                                       this.order = formatSortingOrder(table.config.sortInitialOrder);
+                                       
+                                       if(checkHeaderMetadata(this) || checkHeaderOptions(table,index)) this.sortDisabled = true;
+                                       
+                                       if(!this.sortDisabled) {
+                                               $(this).addClass(table.config.cssHeader);
+                                       }
+                                       
+                                       // add cell to headerList
+                                       table.config.headerList[index]= this;
+                               });
+                               
+                               if(table.config.debug) { benchmark("Built headers:", time); log($tableHeaders); }
+                               
+                               return $tableHeaders;
+                               
+                       };
+                                               
+                       function checkCellColSpan(table, rows, row) {
+                var arr = [], r = table.tHead.rows, c = r[row].cells;
+                               
+                               for(var i=0; i < c.length; i++) {
+                                       var cell = c[i];
+                                       
+                                       if ( cell.colSpan > 1) { 
+                                               arr = arr.concat(checkCellColSpan(table, headerArr,row++));
+                                       } else  {
+                                               if(table.tHead.length == 1 || (cell.rowSpan > 1 || !r[row+1])) {
+                                                       arr.push(cell);
+                                               }
+                                               //headerArr[row] = (i+row);
+                                       }
+                               }
+                               return arr;
+                       };
+                       
+                       function checkHeaderMetadata(cell) {
+                               if(($.metadata) && ($(cell).metadata().sorter === false)) { return true; };
+                               return false;
+                       }
+                       
+                       function checkHeaderOptions(table,i) {  
+                               if((table.config.headers[i]) && (table.config.headers[i].sorter === false)) { return true; };
+                               return false;
+                       }
+                       
+                       function applyWidget(table) {
+                               var c = table.config.widgets;
+                               var l = c.length;
+                               for(var i=0; i < l; i++) {
+                                       
+                                       getWidgetById(c[i]).format(table);
+                               }
+                               
+                       }
+                       
+                       function getWidgetById(name) {
+                               var l = widgets.length;
+                               for(var i=0; i < l; i++) {
+                                       if(widgets[i].id.toLowerCase() == name.toLowerCase() ) {
+                                               return widgets[i]; 
+                                       }
+                               }
+                       };
+                       
+                       function formatSortingOrder(v) {
+                               
+                               if(typeof(v) != "Number") {
+                                       i = (v.toLowerCase() == "desc") ? 1 : 0;
+                               } else {
+                                       i = (v == (0 || 1)) ? v : 0;
+                               }
+                               return i;
+                       }
+                       
+                       function isValueInArray(v, a) {
+                               var l = a.length;
+                               for(var i=0; i < l; i++) {
+                                       if(a[i][0] == v) {
+                                               return true;    
+                                       }
+                               }
+                               return false;
+                       }
+                               
+                       function setHeadersCss(table,$headers, list, css) {
+                               // remove all header information
+                               $headers.removeClass(css[0]).removeClass(css[1]);
+                               
+                               var h = [];
+                               $headers.each(function(offset) {
+                                               if(!this.sortDisabled) {
+                                                       h[this.column] = $(this);                                       
+                                               }
+                               });
+                               
+                               var l = list.length; 
+                               for(var i=0; i < l; i++) {
+                                       h[list[i][0]].addClass(css[list[i][1]]);
+                               }
+                       }
+                       
+                       function fixColumnWidth(table,$headers) {
+                               var c = table.config;
+                               if(c.widthFixed) {
+                                       var colgroup = $('<colgroup>');
+                                       $("tr:first td",table.tBodies[0]).each(function() {
+                                               colgroup.append($('<col>').css('width',$(this).width()));
+                                       });
+                                       $(table).prepend(colgroup);
+                               };
+                       }
+                       
+                       function updateHeaderSortCount(table,sortList) {
+                               var c = table.config, l = sortList.length;
+                               for(var i=0; i < l; i++) {
+                                       var s = sortList[i], o = c.headerList[s[0]];
+                                       o.count = s[1];
+                                       o.count++;
+                               }
+                       }
+                       
+                       /* sorting methods */
+                       function multisort(table,sortList,cache) {
+                               
+                               if(table.config.debug) { var sortTime = new Date(); }
+                               
+                               var dynamicExp = "var sortWrapper = function(a,b) {", l = sortList.length;
+                                       
+                               for(var i=0; i < l; i++) {
+                                       
+                                       var c = sortList[i][0];
+                                       var order = sortList[i][1];
+                                       var s = (getCachedSortType(table.config.parsers,c) == "text") ? ((order == 0) ? "sortText" : "sortTextDesc") : ((order == 0) ? "sortNumeric" : "sortNumericDesc");
+                                       
+                                       var e = "e" + i;
+                                       
+                                       dynamicExp += "var " + e + " = " + s + "(a[" + c + "],b[" + c + "]); ";
+                                       dynamicExp += "if(" + e + ") { return " + e + "; } ";
+                                       dynamicExp += "else { ";
+                               }
+                               
+                               // if value is the same keep orignal order      
+                               var orgOrderCol = cache.normalized[0].length - 1;
+                               dynamicExp += "return a[" + orgOrderCol + "]-b[" + orgOrderCol + "];";
+                                               
+                               for(var i=0; i < l; i++) {
+                                       dynamicExp += "}; ";
+                               }
+                               
+                               dynamicExp += "return 0; ";     
+                               dynamicExp += "}; ";    
+                               
+                               eval(dynamicExp);
+                               
+                               cache.normalized.sort(sortWrapper);
+                               
+                               if(table.config.debug) { benchmark("Sorting on " + sortList.toString() + " and dir " + order+ " time:", sortTime); }
+                               
+                               return cache;
+                       };
+                       
+                       function sortText(a,b) {
+                               return ((a < b) ? -1 : ((a > b) ? 1 : 0));
+                       };
+                       
+                       function sortTextDesc(a,b) {
+                               return ((b < a) ? -1 : ((b > a) ? 1 : 0));
+                       };      
+                       
+                       function sortNumeric(a,b) {
+                               return a-b;
+                       };
+                       
+                       function sortNumericDesc(a,b) {
+                               return b-a;
+                       };
+                       
+                       function getCachedSortType(parsers,i) {
+                               return parsers[i].type;
+                       };
+                       
+                       /* public methods */
+                       this.construct = function(settings) {
+
+                               return this.each(function() {
+                                       
+                                       if(!this.tHead || !this.tBodies) return;
+                                       
+                                       var $this, $document,$headers, cache, config, shiftDown = 0, sortOrder;
+                                       
+                                       this.config = {};
+                                       
+                                       config = $.extend(this.config, $.tablesorter.defaults, settings);
+                                       
+                                       // store common expression for speed                                    
+                                       $this = $(this);
+                                       
+                                       // build headers
+                                       $headers = buildHeaders(this);
+                                       
+                                       // try to auto detect column type, and store in tables config
+                                       this.config.parsers = buildParserCache(this,$headers);
+                                       
+                                       
+                                       // build the cache for the tbody cells
+                                       cache = buildCache(this);
+                                       
+                                       // get the css class names, could be done else where.
+                                       var sortCSS = [config.cssDesc,config.cssAsc];
+                                       
+                                       // fixate columns if the users supplies the fixedWidth option
+                                       fixColumnWidth(this);
+                                       
+                                       // apply event handling to headers
+                                       // this is to big, perhaps break it out?
+                                       $headers.click(function(e) {
+                                               
+                                               $this.trigger("sortStart");
+                                               
+                                               var totalRows = ($this[0].tBodies[0] && $this[0].tBodies[0].rows.length) || 0;
+                                               
+                                               if(!this.sortDisabled && totalRows > 0) {
+                                                       
+                                                       
+                                                       // store exp, for speed
+                                                       var $cell = $(this);
+       
+                                                       // get current column index
+                                                       var i = this.column;
+                                                       
+                                                       // get current column sort order
+                                                       this.order = this.count++ % 2;
+                                                       
+                                                       // user only whants to sort on one column
+                                                       if(!e[config.sortMultiSortKey]) {
+                                                               
+                                                               // flush the sort list
+                                                               config.sortList = [];
+                                                               
+                                                               if(config.sortForce != null) {
+                                                                       var a = config.sortForce; 
+                                                                       for(var j=0; j < a.length; j++) {
+                                                                               if(a[j][0] != i) {
+                                                                                       config.sortList.push(a[j]);
+                                                                               }
+                                                                       }
+                                                               }
+                                                               
+                                                               // add column to sort list
+                                                               config.sortList.push([i,this.order]);
+                                                       
+                                                       // multi column sorting
+                                                       } else {
+                                                               // the user has clicked on an all ready sortet column.
+                                                               if(isValueInArray(i,config.sortList)) {  
+                                                                       
+                                                                       // revers the sorting direction for all tables.
+                                                                       for(var j=0; j < config.sortList.length; j++) {
+                                                                               var s = config.sortList[j], o = config.headerList[s[0]];
+                                                                               if(s[0] == i) {
+                                                                                       o.count = s[1];
+                                                                                       o.count++;
+                                                                                       s[1] = o.count % 2;
+                                                                               }
+                                                                       }       
+                                                               } else {
+                                                                       // add column to sort list array
+                                                                       config.sortList.push([i,this.order]);
+                                                               }
+                                                       };
+                                                       setTimeout(function() {
+                                                               //set css for headers
+                                                               setHeadersCss($this[0],$headers,config.sortList,sortCSS);
+                                                               appendToTable($this[0],multisort($this[0],config.sortList,cache));
+                                                       },1);
+                                                       // stop normal event by returning false
+                                                       return false;
+                                               }
+                                       // cancel selection     
+                                       }).mousedown(function() {
+                                               if(config.cancelSelection) {
+                                                       this.onselectstart = function() {return false};
+                                                       return false;
+                                               }
+                                       });
+                                       
+                                       // apply easy methods that trigger binded events
+                                       $this.bind("update",function() {
+                                               
+                                               // rebuild parsers.
+                                               this.config.parsers = buildParserCache(this,$headers);
+                                               
+                                               // rebuild the cache map
+                                               cache = buildCache(this);
+                                               
+                                       }).bind("sorton",function(e,list) {
+                                               
+                                               $(this).trigger("sortStart");
+                                               
+                                               config.sortList = list;
+                                               
+                                               // update and store the sortlist
+                                               var sortList = config.sortList;
+                                               
+                                               // update header count index
+                                               updateHeaderSortCount(this,sortList);
+                                               
+                                               //set css for headers
+                                               setHeadersCss(this,$headers,sortList,sortCSS);
+                                               
+                                               
+                                               // sort the table and append it to the dom
+                                               appendToTable(this,multisort(this,sortList,cache));
+
+                                       }).bind("appendCache",function() {
+                                               
+                                               appendToTable(this,cache);
+                                       
+                                       }).bind("applyWidgetId",function(e,id) {
+                                               
+                                               getWidgetById(id).format(this);
+                                               
+                                       }).bind("applyWidgets",function() {
+                                               // apply widgets
+                                               applyWidget(this);
+                                       });
+                                       
+                                       if($.metadata && ($(this).metadata() && $(this).metadata().sortlist)) {
+                                               config.sortList = $(this).metadata().sortlist;
+                                       }
+                                       // if user has supplied a sort list to constructor.
+                                       if(config.sortList.length > 0) {
+                                               $this.trigger("sorton",[config.sortList]);      
+                                       }
+                                       
+                                       // apply widgets
+                                       applyWidget(this);
+                               });
+                       };
+                       
+                       this.addParser = function(parser) {
+                               var l = parsers.length, a = true;
+                               for(var i=0; i < l; i++) {
+                                       if(parsers[i].id.toLowerCase() == parser.id.toLowerCase()) {
+                                               a = false;
+                                       }
+                               }
+                               if(a) { parsers.push(parser); };
+                       };
+                       
+                       this.addWidget = function(widget) {
+                               widgets.push(widget);
+                       };
+                       
+                       this.formatFloat = function(s) {
+                               var i = parseFloat(s);
+                               return (isNaN(i)) ? 0 : i;
+                       };
+                       this.formatInt = function(s) {
+                               var i = parseInt(s);
+                               return (isNaN(i)) ? 0 : i;
+                       };
+                       
+                       this.isDigit = function(s,config) {
+                               var DECIMAL = '\\' + config.decimal;
+                               var exp = '/(^[+]?0(' + DECIMAL +'0+)?$)|(^([-+]?[1-9][0-9]*)$)|(^([-+]?((0?|[1-9][0-9]*)' + DECIMAL +'(0*[1-9][0-9]*)))$)|(^[-+]?[1-9]+[0-9]*' + DECIMAL +'0+$)/';
+                               return RegExp(exp).test($.trim(s));
+                       };
+                       
+                       this.clearTableBody = function(table) {
+                               if($.browser.msie) {
+                                       function empty() {
+                                               while ( this.firstChild ) this.removeChild( this.firstChild );
+                                       }
+                                       empty.apply(table.tBodies[0]);
+                               } else {
+                                       table.tBodies[0].innerHTML = "";
+                               }
+                       };
+               }
+       });
+       
+       // extend plugin scope
+       $.fn.extend({
+        tablesorter: $.tablesorter.construct
+       });
+       
+       var ts = $.tablesorter;
+       
+       // add default parsers
+       ts.addParser({
+               id: "text",
+               is: function(s) {
+                       return true;
+               },
+               format: function(s) {
+                       return $.trim(s.toLowerCase());
+               },
+               type: "text"
+       });
+       
+       ts.addParser({
+               id: "digit",
+               is: function(s,table) {
+                       var c = table.config;
+                       return $.tablesorter.isDigit(s,c);
+               },
+               format: function(s) {
+                       return $.tablesorter.formatFloat(s);
+               },
+               type: "numeric"
+       });
+       
+       ts.addParser({
+               id: "currency",
+               is: function(s) {
+                       return /^[£$€?.]/.test(s);
+               },
+               format: function(s) {
+                       return $.tablesorter.formatFloat(s.replace(new RegExp(/[^0-9.]/g),""));
+               },
+               type: "numeric"
+       });
+       
+       ts.addParser({
+               id: "ipAddress",
+               is: function(s) {
+                       return /^\d{2,3}[\.]\d{2,3}[\.]\d{2,3}[\.]\d{2,3}$/.test(s);
+               },
+               format: function(s) {
+                       var a = s.split("."), r = "", l = a.length;
+                       for(var i = 0; i < l; i++) {
+                               var item = a[i];
+                               if(item.length == 2) {
+                                       r += "0" + item;
+                               } else {
+                                       r += item;
+                               }
+                       }
+                       return $.tablesorter.formatFloat(r);
+               },
+               type: "numeric"
+       });
+       
+       ts.addParser({
+               id: "url",
+               is: function(s) {
+                       return /^(https?|ftp|file):\/\/$/.test(s);
+               },
+               format: function(s) {
+                       return jQuery.trim(s.replace(new RegExp(/(https?|ftp|file):\/\//),''));
+               },
+               type: "text"
+       });
+       
+       ts.addParser({
+               id: "isoDate",
+               is: function(s) {
+                       return /^\d{4}[\/-]\d{1,2}[\/-]\d{1,2}$/.test(s);
+               },
+               format: function(s) {
+                       return $.tablesorter.formatFloat((s != "") ? new Date(s.replace(new RegExp(/-/g),"/")).getTime() : "0");
+               },
+               type: "numeric"
+       });
+               
+       ts.addParser({
+               id: "percent",
+               is: function(s) { 
+                       return /\%$/.test($.trim(s));
+               },
+               format: function(s) {
+                       return $.tablesorter.formatFloat(s.replace(new RegExp(/%/g),""));
+               },
+               type: "numeric"
+       });
+
+       ts.addParser({
+               id: "usLongDate",
+               is: function(s) {
+                       return s.match(new RegExp(/^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/));
+               },
+               format: function(s) {
+                       return $.tablesorter.formatFloat(new Date(s).getTime());
+               },
+               type: "numeric"
+       });
+
+       ts.addParser({
+               id: "shortDate",
+               is: function(s) {
+                       return /\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/.test(s);
+               },
+               format: function(s,table) {
+                       var c = table.config;
+                       s = s.replace(/\-/g,"/");
+                       if(c.dateFormat == "us") {
+                               // reformat the string in ISO format
+                               s = s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/, "$3/$1/$2");
+                       } else if(c.dateFormat == "uk") {
+                               //reformat the string in ISO format
+                               s = s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/, "$3/$2/$1");
+                       } else if(c.dateFormat == "dd/mm/yy" || c.dateFormat == "dd-mm-yy") {
+                               s = s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2})/, "$1/$2/$3");     
+                       }
+                       return $.tablesorter.formatFloat(new Date(s).getTime());
+               },
+               type: "numeric"
+       });
+
+       ts.addParser({
+           id: "time",
+           is: function(s) {
+               return /^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/.test(s);
+           },
+           format: function(s) {
+               return $.tablesorter.formatFloat(new Date("2000/01/01 " + s).getTime());
+           },
+         type: "numeric"
+       });
+       
+       
+       ts.addParser({
+           id: "metadata",
+           is: function(s) {
+               return false;
+           },
+           format: function(s,table,cell) {
+                       var c = table.config, p = (!c.parserMetadataName) ? 'sortValue' : c.parserMetadataName;
+               return $(cell).metadata()[p];
+           },
+         type: "numeric"
+       });
+       
+       // add default widgets
+       ts.addWidget({
+               id: "zebra",
+               format: function(table) {
+                       if(table.config.debug) { var time = new Date(); }
+                       $("tr:visible",table.tBodies[0])
+               .filter(':even')
+               .removeClass(table.config.widgetZebra.css[1]).addClass(table.config.widgetZebra.css[0])
+               .end().filter(':odd')
+               .removeClass(table.config.widgetZebra.css[0]).addClass(table.config.widgetZebra.css[1]);
+                       if(table.config.debug) { $.tablesorter.benchmark("Applying Zebra widget", time); }
+               }
+       });     
+})(jQuery);
\ No newline at end of file
diff --git a/web/js/jquery.timeago.js b/web/js/jquery.timeago.js
new file mode 100644 (file)
index 0000000..7bce690
--- /dev/null
@@ -0,0 +1,140 @@
+/*
+ * timeago: a jQuery plugin, version: 0.8.2 (2010-02-16)
+ * @requires jQuery v1.2.3 or later
+ *
+ * Timeago is a jQuery plugin that makes it easy to support automatically
+ * updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago").
+ *
+ * For usage and examples, visit:
+ * http://timeago.yarp.com/
+ *
+ * Licensed under the MIT:
+ * http://www.opensource.org/licenses/mit-license.php
+ *
+ * Copyright (c) 2008-2010, Ryan McGeary (ryanonjavascript -[at]- mcgeary [*dot*] org)
+ */
+(function($) {
+  $.timeago = function(timestamp) {
+    if (timestamp instanceof Date) return inWords(timestamp);
+    else if (typeof timestamp == "string") return inWords($.timeago.parse(timestamp));
+    else return inWords($.timeago.datetime(timestamp));
+  };
+  var $t = $.timeago;
+
+  $.extend($.timeago, {
+    settings: {
+      refreshMillis: 60000,
+      allowFuture: false,
+      strings: {
+        prefixAgo: null,
+        prefixFromNow: null,
+        suffixAgo: "ago",
+        suffixFromNow: "from now",
+        ago: null, // DEPRECATED, use suffixAgo
+        fromNow: null, // DEPRECATED, use suffixFromNow
+        seconds: "less than a minute",
+        minute: "about a minute",
+        minutes: "%d minutes",
+        hour: "about an hour",
+        hours: "about %d hours",
+        day: "a day",
+        days: "%d days",
+        month: "about a month",
+        months: "%d months",
+        year: "about a year",
+        years: "%d years"
+      }
+    },
+    inWords: function(distanceMillis) {
+      var $l = this.settings.strings;
+      var prefix = $l.prefixAgo;
+      var suffix = $l.suffixAgo || $l.ago;
+      if (this.settings.allowFuture) {
+        if (distanceMillis < 0) {
+          prefix = $l.prefixFromNow;
+          suffix = $l.suffixFromNow || $l.fromNow;
+        }
+        distanceMillis = Math.abs(distanceMillis);
+      }
+
+      var seconds = distanceMillis / 1000;
+      var minutes = seconds / 60;
+      var hours = minutes / 60;
+      var days = hours / 24;
+      var years = days / 365;
+
+      var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) ||
+        seconds < 90 && substitute($l.minute, 1) ||
+        minutes < 45 && substitute($l.minutes, Math.round(minutes)) ||
+        minutes < 90 && substitute($l.hour, 1) ||
+        hours < 24 && substitute($l.hours, Math.round(hours)) ||
+        hours < 48 && substitute($l.day, 1) ||
+        days < 30 && substitute($l.days, Math.floor(days)) ||
+        days < 60 && substitute($l.month, 1) ||
+        days < 365 && substitute($l.months, Math.floor(days / 30)) ||
+        years < 2 && substitute($l.year, 1) ||
+        substitute($l.years, Math.floor(years));
+
+      return $.trim([prefix, words, suffix].join(" "));
+    },
+    parse: function(iso8601) {
+      var s = $.trim(iso8601);
+      s = s.replace(/-/,"/").replace(/-/,"/");
+      s = s.replace(/T/," ").replace(/Z/," UTC");
+      s = s.replace(/([\+-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400
+      return new Date(s);
+    },
+    datetime: function(elem) {
+      // jQuery's `is()` doesn't play well with HTML5 in IE
+      var isTime = $(elem).get(0).tagName.toLowerCase() == "time"; // $(elem).is("time");
+      var iso8601 = isTime ? $(elem).attr("datetime") : $(elem).attr("title");
+      return $t.parse(iso8601);
+    }
+  });
+
+  $.fn.timeago = function() {
+    var self = this;
+    self.each(refresh);
+
+    var $s = $t.settings;
+    if ($s.refreshMillis > 0) {
+      setInterval(function() { self.each(refresh); }, $s.refreshMillis);
+    }
+    return self;
+  };
+
+  function refresh() {
+    var data = prepareData(this);
+    if (!isNaN(data.datetime)) {
+      $(this).text(inWords(data.datetime));
+    }
+    return this;
+  }
+
+  function prepareData(element) {
+    element = $(element);
+    if (!element.data("timeago")) {
+      element.data("timeago", { datetime: $t.datetime(element) });
+      var text = $.trim(element.text());
+      if (text.length > 0) element.attr("title", text);
+    }
+    return element.data("timeago");
+  }
+
+  function inWords(date) {
+    return $t.inWords(distance(date));
+  }
+
+  function distance(date) {
+    return (new Date().getTime() - date.getTime());
+  }
+
+  function substitute(stringOrFunction, value) {
+    var string = $.isFunction(stringOrFunction) ? stringOrFunction(value) : stringOrFunction;
+    return string.replace(/%d/i, value);
+  }
+
+  // fix for IE6 suckage
+  document.createElement("abbr");
+  document.createElement("time");
+})(jQuery);
diff --git a/web/js/jquery.treeview.js b/web/js/jquery.treeview.js
new file mode 100644 (file)
index 0000000..bc5d9e4
--- /dev/null
@@ -0,0 +1,251 @@
+/*
+ * Treeview 1.4 - jQuery plugin to hide and show branches of a tree
+ * 
+ * http://bassistance.de/jquery-plugins/jquery-plugin-treeview/
+ * http://docs.jquery.com/Plugins/Treeview
+ *
+ * Copyright (c) 2007 Jörn Zaefferer
+ *
+ * Dual licensed under the MIT and GPL licenses:
+ *   http://www.opensource.org/licenses/mit-license.php
+ *   http://www.gnu.org/licenses/gpl.html
+ *
+ * Revision: $Id: jquery.treeview.js 4684 2008-02-07 19:08:06Z joern.zaefferer $
+ *
+ */
+
+;(function($) {
+
+       $.extend($.fn, {
+               swapClass: function(c1, c2) {
+                       var c1Elements = this.filter('.' + c1);
+                       this.filter('.' + c2).removeClass(c2).addClass(c1);
+                       c1Elements.removeClass(c1).addClass(c2);
+                       return this;
+               },
+               replaceClass: function(c1, c2) {
+                       return this.filter('.' + c1).removeClass(c1).addClass(c2).end();
+               },
+               hoverClass: function(className) {
+                       className = className || "hover";
+                       return this.hover(function() {
+                               $(this).addClass(className);
+                       }, function() {
+                               $(this).removeClass(className);
+                       });
+               },
+               heightToggle: function(animated, callback) {
+                       animated ?
+                               this.animate({ height: "toggle" }, animated, callback) :
+                               this.each(function(){
+                                       jQuery(this)[ jQuery(this).is(":hidden") ? "show" : "hide" ]();
+                                       if(callback)
+                                               callback.apply(this, arguments);
+                               });
+               },
+               heightHide: function(animated, callback) {
+                       if (animated) {
+                               this.animate({ height: "hide" }, animated, callback);
+                       } else {
+                               this.hide();
+                               if (callback)
+                                       this.each(callback);                            
+                       }
+               },
+               prepareBranches: function(settings) {
+                       if (!settings.prerendered) {
+                               // mark last tree items
+                               this.filter(":last-child:not(ul)").addClass(CLASSES.last);
+                               // collapse whole tree, or only those marked as closed, anyway except those marked as open
+                               this.filter((settings.collapsed ? "" : "." + CLASSES.closed) + ":not(." + CLASSES.open + ")").find(">ul").hide();
+                       }
+                       // return all items with sublists
+                       return this.filter(":has(>ul)");
+               },
+               applyClasses: function(settings, toggler) {
+                       this.filter(":has(>ul):not(:has(>a))").find(">span").click(function(event) {
+                               toggler.apply($(this).next());
+                       }).add( $("a", this) ).hoverClass();
+                       
+                       if (!settings.prerendered) {
+                               // handle closed ones first
+                               this.filter(":has(>ul:hidden)")
+                                               .addClass(CLASSES.expandable)
+                                               .replaceClass(CLASSES.last, CLASSES.lastExpandable);
+                                               
+                               // handle open ones
+                               this.not(":has(>ul:hidden)")
+                                               .addClass(CLASSES.collapsable)
+                                               .replaceClass(CLASSES.last, CLASSES.lastCollapsable);
+                                               
+                   // create hitarea
+                               this.prepend("<div class=\"" + CLASSES.hitarea + "\"/>").find("div." + CLASSES.hitarea).each(function() {
+                                       var classes = "";
+                                       $.each($(this).parent().attr("class").split(" "), function() {
+                                               classes += this + "-hitarea ";
+                                       });
+                                       $(this).addClass( classes );
+                               });
+                       }
+                       
+                       // apply event to hitarea
+                       this.find("div." + CLASSES.hitarea).click( toggler );
+               },
+               treeview: function(settings) {
+                       
+                       settings = $.extend({
+                               cookieId: "treeview"
+                       }, settings);
+                       
+                       if (settings.add) {
+                               return this.trigger("add", [settings.add]);
+                       }
+                       
+                       if ( settings.toggle ) {
+                               var callback = settings.toggle;
+                               settings.toggle = function() {
+                                       return callback.apply($(this).parent()[0], arguments);
+                               };
+                       }
+               
+                       // factory for treecontroller
+                       function treeController(tree, control) {
+                               // factory for click handlers
+                               function handler(filter) {
+                                       return function() {
+                                               // reuse toggle event handler, applying the elements to toggle
+                                               // start searching for all hitareas
+                                               toggler.apply( $("div." + CLASSES.hitarea, tree).filter(function() {
+                                                       // for plain toggle, no filter is provided, otherwise we need to check the parent element
+                                                       return filter ? $(this).parent("." + filter).length : true;
+                                               }) );
+                                               return false;
+                                       };
+                               }
+                               // click on first element to collapse tree
+                               $("a:eq(0)", control).click( handler(CLASSES.collapsable) );
+                               // click on second to expand tree
+                               $("a:eq(1)", control).click( handler(CLASSES.expandable) );
+                               // click on third to toggle tree
+                               $("a:eq(2)", control).click( handler() ); 
+                       }
+               
+                       // handle toggle event
+                       function toggler() {
+                               $(this)
+                                       .parent()
+                                       // swap classes for hitarea
+                                       .find(">.hitarea")
+                                               .swapClass( CLASSES.collapsableHitarea, CLASSES.expandableHitarea )
+                                               .swapClass( CLASSES.lastCollapsableHitarea, CLASSES.lastExpandableHitarea )
+                                       .end()
+                                       // swap classes for parent li
+                                       .swapClass( CLASSES.collapsable, CLASSES.expandable )
+                                       .swapClass( CLASSES.lastCollapsable, CLASSES.lastExpandable )
+                                       // find child lists
+                                       .find( ">ul" )
+                                       // toggle them
+                                       .heightToggle( settings.animated, settings.toggle );
+                               if ( settings.unique ) {
+                                       $(this).parent()
+                                               .siblings()
+                                               // swap classes for hitarea
+                                               .find(">.hitarea")
+                                                       .replaceClass( CLASSES.collapsableHitarea, CLASSES.expandableHitarea )
+                                                       .replaceClass( CLASSES.lastCollapsableHitarea, CLASSES.lastExpandableHitarea )
+                                               .end()
+                                               .replaceClass( CLASSES.collapsable, CLASSES.expandable )
+                                               .replaceClass( CLASSES.lastCollapsable, CLASSES.lastExpandable )
+                                               .find( ">ul" )
+                                               .heightHide( settings.animated, settings.toggle );
+                               }
+                       }
+                       
+                       function serialize() {
+                               function binary(arg) {
+                                       return arg ? 1 : 0;
+                               }
+                               var data = [];
+                               branches.each(function(i, e) {
+                                       data[i] = $(e).is(":has(>ul:visible)") ? 1 : 0;
+                               });
+                               $.cookie(settings.cookieId, data.join("") );
+                       }
+                       
+                       function deserialize() {
+                               var stored = $.cookie(settings.cookieId);
+                               if ( stored ) {
+                                       var data = stored.split("");
+                                       branches.each(function(i, e) {
+                                               $(e).find(">ul")[ parseInt(data[i]) ? "show" : "hide" ]();
+                                       });
+                               }
+                       }
+                       
+                       // add treeview class to activate styles
+                       this.addClass("treeview");
+                       
+                       // prepare branches and find all tree items with child lists
+                       var branches = this.find("li").prepareBranches(settings);
+                       
+                       switch(settings.persist) {
+                       case "cookie":
+                               var toggleCallback = settings.toggle;
+                               settings.toggle = function() {
+                                       serialize();
+                                       if (toggleCallback) {
+                                               toggleCallback.apply(this, arguments);
+                                       }
+                               };
+                               deserialize();
+                               break;
+                       case "location":
+                               var current = this.find("a").filter(function() { return this.href.toLowerCase() == location.href.toLowerCase(); });
+                               if ( current.length ) {
+                                       current.addClass("selected").parents("ul, li").add( current.next() ).show();
+                               }
+                               break;
+                       }
+                       
+                       branches.applyClasses(settings, toggler);
+                               
+                       // if control option is set, create the treecontroller and show it
+                       if ( settings.control ) {
+                               treeController(this, settings.control);
+                               $(settings.control).show();
+                       }
+                       
+                       return this.bind("add", function(event, branches) {
+                               $(branches).prev()
+                                       .removeClass(CLASSES.last)
+                                       .removeClass(CLASSES.lastCollapsable)
+                                       .removeClass(CLASSES.lastExpandable)
+                               .find(">.hitarea")
+                                       .removeClass(CLASSES.lastCollapsableHitarea)
+                                       .removeClass(CLASSES.lastExpandableHitarea);
+                               $(branches).find("li").andSelf().prepareBranches(settings).applyClasses(settings, toggler);
+                       });
+               }
+       });
+       
+       // classes used by the plugin
+       // need to be styled via external stylesheet, see first example
+       var CLASSES = $.fn.treeview.classes = {
+               open: "open",
+               closed: "closed",
+               expandable: "expandable",
+               expandableHitarea: "expandable-hitarea",
+               lastExpandableHitarea: "lastExpandable-hitarea",
+               collapsable: "collapsable",
+               collapsableHitarea: "collapsable-hitarea",
+               lastCollapsableHitarea: "lastCollapsable-hitarea",
+               lastCollapsable: "lastCollapsable",
+               lastExpandable: "lastExpandable",
+               last: "last",
+               hitarea: "hitarea"
+       };
+       
+       // provide backwards compability
+       $.fn.Treeview = $.fn.treeview;
+       
+})(jQuery);
\ No newline at end of file
diff --git a/web/js/json2.js b/web/js/json2.js
new file mode 100644 (file)
index 0000000..a1a3b17
--- /dev/null
@@ -0,0 +1,482 @@
+/*
+    http://www.JSON.org/json2.js
+    2010-03-20
+
+    Public Domain.
+
+    NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
+
+    See http://www.JSON.org/js.html
+
+
+    This code should be minified before deployment.
+    See http://javascript.crockford.com/jsmin.html
+
+    USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO
+    NOT CONTROL.
+
+
+    This file creates a global JSON object containing two methods: stringify
+    and parse.
+
+        JSON.stringify(value, replacer, space)
+            value       any JavaScript value, usually an object or array.
+
+            replacer    an optional parameter that determines how object
+                        values are stringified for objects. It can be a
+                        function or an array of strings.
+
+            space       an optional parameter that specifies the indentation
+                        of nested structures. If it is omitted, the text will
+                        be packed without extra whitespace. If it is a number,
+                        it will specify the number of spaces to indent at each
+                        level. If it is a string (such as '\t' or '&nbsp;'),
+                        it contains the characters used to indent at each level.
+
+            This method produces a JSON text from a JavaScript value.
+
+            When an object value is found, if the object contains a toJSON
+            method, its toJSON method will be called and the result will be
+            stringified. A toJSON method does not serialize: it returns the
+            value represented by the name/value pair that should be serialized,
+            or undefined if nothing should be serialized. The toJSON method
+            will be passed the key associated with the value, and this will be
+            bound to the value
+
+            For example, this would serialize Dates as ISO strings.
+
+                Date.prototype.toJSON = function (key) {
+                    function f(n) {
+                        // Format integers to have at least two digits.
+                        return n < 10 ? '0' + n : n;
+                    }
+
+                    return this.getUTCFullYear()   + '-' +
+                         f(this.getUTCMonth() + 1) + '-' +
+                         f(this.getUTCDate())      + 'T' +
+                         f(this.getUTCHours())     + ':' +
+                         f(this.getUTCMinutes())   + ':' +
+                         f(this.getUTCSeconds())   + 'Z';
+                };
+
+            You can provide an optional replacer method. It will be passed the
+            key and value of each member, with this bound to the containing
+            object. The value that is returned from your method will be
+            serialized. If your method returns undefined, then the member will
+            be excluded from the serialization.
+
+            If the replacer parameter is an array of strings, then it will be
+            used to select the members to be serialized. It filters the results
+            such that only members with keys listed in the replacer array are
+            stringified.
+
+            Values that do not have JSON representations, such as undefined or
+            functions, will not be serialized. Such values in objects will be
+            dropped; in arrays they will be replaced with null. You can use
+            a replacer function to replace those with JSON values.
+            JSON.stringify(undefined) returns undefined.
+
+            The optional space parameter produces a stringification of the
+            value that is filled with line breaks and indentation to make it
+            easier to read.
+
+            If the space parameter is a non-empty string, then that string will
+            be used for indentation. If the space parameter is a number, then
+            the indentation will be that many spaces.
+
+            Example:
+
+            text = JSON.stringify(['e', {pluribus: 'unum'}]);
+            // text is '["e",{"pluribus":"unum"}]'
+
+
+            text = JSON.stringify(['e', {pluribus: 'unum'}], null, '\t');
+            // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]'
+
+            text = JSON.stringify([new Date()], function (key, value) {
+                return this[key] instanceof Date ?
+                    'Date(' + this[key] + ')' : value;
+            });
+            // text is '["Date(---current time---)"]'
+
+
+        JSON.parse(text, reviver)
+            This method parses a JSON text to produce an object or array.
+            It can throw a SyntaxError exception.
+
+            The optional reviver parameter is a function that can filter and
+            transform the results. It receives each of the keys and values,
+            and its return value is used instead of the original value.
+            If it returns what it received, then the structure is not modified.
+            If it returns undefined then the member is deleted.
+
+            Example:
+
+            // Parse the text. Values that look like ISO date strings will
+            // be converted to Date objects.
+
+            myData = JSON.parse(text, function (key, value) {
+                var a;
+                if (typeof value === 'string') {
+                    a =
+/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value);
+                    if (a) {
+                        return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4],
+                            +a[5], +a[6]));
+                    }
+                }
+                return value;
+            });
+
+            myData = JSON.parse('["Date(09/09/2001)"]', function (key, value) {
+                var d;
+                if (typeof value === 'string' &&
+                        value.slice(0, 5) === 'Date(' &&
+                        value.slice(-1) === ')') {
+                    d = new Date(value.slice(5, -1));
+                    if (d) {
+                        return d;
+                    }
+                }
+                return value;
+            });
+
+
+    This is a reference implementation. You are free to copy, modify, or
+    redistribute.
+*/
+
+/*jslint evil: true, strict: false */
+
+/*members "", "\b", "\t", "\n", "\f", "\r", "\"", JSON, "\\", apply,
+    call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours,
+    getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join,
+    lastIndex, length, parse, prototype, push, replace, slice, stringify,
+    test, toJSON, toString, valueOf
+*/
+
+
+// Create a JSON object only if one does not already exist. We create the
+// methods in a closure to avoid creating global variables.
+
+if (!this.JSON) {
+    this.JSON = {};
+}
+
+(function () {
+
+    function f(n) {
+        // Format integers to have at least two digits.
+        return n < 10 ? '0' + n : n;
+    }
+
+    if (typeof Date.prototype.toJSON !== 'function') {
+
+        Date.prototype.toJSON = function (key) {
+
+            return isFinite(this.valueOf()) ?
+                   this.getUTCFullYear()   + '-' +
+                 f(this.getUTCMonth() + 1) + '-' +
+                 f(this.getUTCDate())      + 'T' +
+                 f(this.getUTCHours())     + ':' +
+                 f(this.getUTCMinutes())   + ':' +
+                 f(this.getUTCSeconds())   + 'Z' : null;
+        };
+
+        String.prototype.toJSON =
+        Number.prototype.toJSON =
+        Boolean.prototype.toJSON = function (key) {
+            return this.valueOf();
+        };
+    }
+
+    var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
+        escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
+        gap,
+        indent,
+        meta = {    // table of character substitutions
+            '\b': '\\b',
+            '\t': '\\t',
+            '\n': '\\n',
+            '\f': '\\f',
+            '\r': '\\r',
+            '"' : '\\"',
+            '\\': '\\\\'
+        },
+        rep;
+
+
+    function quote(string) {
+
+// If the string contains no control characters, no quote characters, and no
+// backslash characters, then we can safely slap some quotes around it.
+// Otherwise we must also replace the offending characters with safe escape
+// sequences.
+
+        escapable.lastIndex = 0;
+        return escapable.test(string) ?
+            '"' + string.replace(escapable, function (a) {
+                var c = meta[a];
+                return typeof c === 'string' ? c :
+                    '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
+            }) + '"' :
+            '"' + string + '"';
+    }
+
+
+    function str(key, holder) {
+
+// Produce a string from holder[key].
+
+        var i,          // The loop counter.
+            k,          // The member key.
+            v,          // The member value.
+            length,
+            mind = gap,
+            partial,
+            value = holder[key];
+
+// If the value has a toJSON method, call it to obtain a replacement value.
+
+        if (value && typeof value === 'object' &&
+                typeof value.toJSON === 'function') {
+            value = value.toJSON(key);
+        }
+
+// If we were called with a replacer function, then call the replacer to
+// obtain a replacement value.
+
+        if (typeof rep === 'function') {
+            value = rep.call(holder, key, value);
+        }
+
+// What happens next depends on the value's type.
+
+        switch (typeof value) {
+        case 'string':
+            return quote(value);
+
+        case 'number':
+
+// JSON numbers must be finite. Encode non-finite numbers as null.
+
+            return isFinite(value) ? String(value) : 'null';
+
+        case 'boolean':
+        case 'null':
+
+// If the value is a boolean or null, convert it to a string. Note:
+// typeof null does not produce 'null'. The case is included here in
+// the remote chance that this gets fixed someday.
+
+            return String(value);
+
+// If the type is 'object', we might be dealing with an object or an array or
+// null.
+
+        case 'object':
+
+// Due to a specification blunder in ECMAScript, typeof null is 'object',
+// so watch out for that case.
+
+            if (!value) {
+                return 'null';
+            }
+
+// Make an array to hold the partial results of stringifying this object value.
+
+            gap += indent;
+            partial = [];
+
+// Is the value an array?
+
+            if (Object.prototype.toString.apply(value) === '[object Array]') {
+
+// The value is an array. Stringify every element. Use null as a placeholder
+// for non-JSON values.
+
+                length = value.length;
+                for (i = 0; i < length; i += 1) {
+                    partial[i] = str(i, value) || 'null';
+                }
+
+// Join all of the elements together, separated with commas, and wrap them in
+// brackets.
+
+                v = partial.length === 0 ? '[]' :
+                    gap ? '[\n' + gap +
+                            partial.join(',\n' + gap) + '\n' +
+                                mind + ']' :
+                          '[' + partial.join(',') + ']';
+                gap = mind;
+                return v;
+            }
+
+// If the replacer is an array, use it to select the members to be stringified.
+
+            if (rep && typeof rep === 'object') {
+                length = rep.length;
+                for (i = 0; i < length; i += 1) {
+                    k = rep[i];
+                    if (typeof k === 'string') {
+                        v = str(k, value);
+                        if (v) {
+                            partial.push(quote(k) + (gap ? ': ' : ':') + v);
+                        }
+                    }
+                }
+            } else {
+
+// Otherwise, iterate through all of the keys in the object.
+
+                for (k in value) {
+                    if (Object.hasOwnProperty.call(value, k)) {
+                        v = str(k, value);
+                        if (v) {
+                            partial.push(quote(k) + (gap ? ': ' : ':') + v);
+                        }
+                    }
+                }
+            }
+
+// Join all of the member texts together, separated with commas,
+// and wrap them in braces.
+
+            v = partial.length === 0 ? '{}' :
+                gap ? '{\n' + gap + partial.join(',\n' + gap) + '\n' +
+                        mind + '}' : '{' + partial.join(',') + '}';
+            gap = mind;
+            return v;
+        }
+    }
+
+// If the JSON object does not yet have a stringify method, give it one.
+
+    if (typeof JSON.stringify !== 'function') {
+        JSON.stringify = function (value, replacer, space) {
+
+// The stringify method takes a value and an optional replacer, and an optional
+// space parameter, and returns a JSON text. The replacer can be a function
+// that can replace values, or an array of strings that will select the keys.
+// A default replacer method can be provided. Use of the space parameter can
+// produce text that is more easily readable.
+
+            var i;
+            gap = '';
+            indent = '';
+
+// If the space parameter is a number, make an indent string containing that
+// many spaces.
+
+            if (typeof space === 'number') {
+                for (i = 0; i < space; i += 1) {
+                    indent += ' ';
+                }
+
+// If the space parameter is a string, it will be used as the indent string.
+
+            } else if (typeof space === 'string') {
+                indent = space;
+            }
+
+// If there is a replacer, it must be a function or an array.
+// Otherwise, throw an error.
+
+            rep = replacer;
+            if (replacer && typeof replacer !== 'function' &&
+                    (typeof replacer !== 'object' ||
+                     typeof replacer.length !== 'number')) {
+                throw new Error('JSON.stringify');
+            }
+
+// Make a fake root object containing our value under the key of ''.
+// Return the result of stringifying the value.
+
+            return str('', {'': value});
+        };
+    }
+
+
+// If the JSON object does not yet have a parse method, give it one.
+
+    if (typeof JSON.parse !== 'function') {
+        JSON.parse = function (text, reviver) {
+
+// The parse method takes a text and an optional reviver function, and returns
+// a JavaScript value if the text is a valid JSON text.
+
+            var j;
+
+            function walk(holder, key) {
+
+// The walk method is used to recursively walk the resulting structure so
+// that modifications can be made.
+
+                var k, v, value = holder[key];
+                if (value && typeof value === 'object') {
+                    for (k in value) {
+                        if (Object.hasOwnProperty.call(value, k)) {
+                            v = walk(value, k);
+                            if (v !== undefined) {
+                                value[k] = v;
+                            } else {
+                                delete value[k];
+                            }
+                        }
+                    }
+                }
+                return reviver.call(holder, key, value);
+            }
+
+
+// Parsing happens in four stages. In the first stage, we replace certain
+// Unicode characters with escape sequences. JavaScript handles many characters
+// incorrectly, either silently deleting them, or treating them as line endings.
+
+            text = String(text);
+            cx.lastIndex = 0;
+            if (cx.test(text)) {
+                text = text.replace(cx, function (a) {
+                    return '\\u' +
+                        ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
+                });
+            }
+
+// In the second stage, we run the text against regular expressions that look
+// for non-JSON patterns. We are especially concerned with '()' and 'new'
+// because they can cause invocation, and '=' because it can cause mutation.
+// But just to be safe, we want to reject all unexpected forms.
+
+// We split the second stage into 4 regexp operations in order to work around
+// crippling inefficiencies in IE's and Safari's regexp engines. First we
+// replace the JSON backslash pairs with '@' (a non-JSON character). Second, we
+// replace all simple value tokens with ']' characters. Third, we delete all
+// open brackets that follow a colon or comma or that begin the text. Finally,
+// we look to see that the remaining characters are only whitespace or ']' or
+// ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval.
+
+            if (/^[\],:{}\s]*$/.
+test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@').
+replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']').
+replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) {
+
+// In the third stage we use the eval function to compile the text into a
+// JavaScript structure. The '{' operator is subject to a syntactic ambiguity
+// in JavaScript: it can begin a block or an object literal. We wrap the text
+// in parens to eliminate the ambiguity.
+
+                j = eval('(' + text + ')');
+
+// In the optional fourth stage, we recursively walk the new structure, passing
+// each name/value pair to a reviver function for possible transformation.
+
+                return typeof reviver === 'function' ?
+                    walk({'': j}, '') : j;
+            }
+
+// If the text is not JSON parseable, then a SyntaxError is thrown.
+
+            throw new SyntaxError('JSON.parse');
+        };
+    }
+}());
diff --git a/web/log.php b/web/log.php
new file mode 100644 (file)
index 0000000..95489d7
--- /dev/null
@@ -0,0 +1,112 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+MTrackACL::requireAllRights('Browser', 'read');
+
+$pi = mtrack_get_pathinfo(true);
+$crumbs = MTrackSCM::makeBreadcrumbs($pi);
+if (!strlen($pi) || $pi == '/') {
+  $pi = '/';
+  $repo = null;
+} else {
+  $repo = MTrackSCM::factory($pi);
+}
+
+if ($repo === null) {
+  throw new Exception("Cannot determine what to log from $pi");
+}
+
+MTrackACL::requireAllRights("repo:$repo->repoid", 'read');
+mtrack_head("Log $pi");
+
+/* Render a bread-crumb enabled location indicator */
+echo "<div class='browselocation'>Location: ";
+$location = null;
+if (isset($_GET['jump'])) {
+  $jump = '?jump=' . urlencode($_GET['jump']);
+  list($object, $ident) = explode(':', $_GET['jump'], 2);
+} else {
+  $_GET['jump'] = '';
+  $jump = '';
+  $object = null;
+  $ident = null;
+}
+$last = array_pop($crumbs);
+foreach ($crumbs as $path) {
+  if (!strlen($path)) {
+    $path = '[root]';
+    echo "<a href='{$ABSWEB}browse.php$jump'>$path</a> / ";
+  } else {
+    $location .= '/' . htmlentities(urlencode($path), ENT_QUOTES, 'utf-8');
+    echo "<a href='{$ABSWEB}log.php$location$jump'>$path</a> / ";
+  }
+}
+
+echo "$last";
+$branches = $repo->getBranches();
+$tags = $repo->getTags();
+if (count($branches) + count($tags)) {
+  $jumps = array("" => "- Select Branch / Tag - ");
+  if (is_array($branches)) {
+    foreach ($branches as $name => $notcare) {
+      $jumps["branch:$name"] = "Branch: $name";
+    }
+  }
+  if (is_array($tags)) {
+    foreach ($tags as $name => $notcare) {
+      $jumps["tag:$name"] = "Tag: $name";
+    }
+  }
+  echo "<form>";
+  echo mtrack_select_box("jump", $jumps, $_GET['jump']);
+  echo "<button type='submit'>Choose</button></form>\n";
+}
+echo "</div>";
+
+$last_day = null;
+$even = 1;
+$hist = $repo->history($pi, null, $object, $ident);
+if (!count($hist)) {
+  echo "<em>No history for the requested path</em>";
+} else {
+  echo "<div class='changesets'>\n";
+  foreach ($hist as $ent) {
+    $class = ($even++ % 2) ? '' : 'odd';
+
+    $ts = strtotime($ent->ctime);
+    $day = date('D, M d Y', $ts);
+    $time = date('H:m', $ts);
+
+    if ($day !== $last_day) {
+      echo "<div class='changesetday'>$day</div>\n";
+      $last_day = $day;
+    }
+    echo "<div class='changeset$class'>\n<div class='changelog'>";
+    echo MTrackWiki::format_to_html($ent->changelog);
+    echo "</div>\n";
+
+    echo "<div class='changeinfo'>\n";
+    echo mtrack_username($ent->changeby, array(
+          'no_name' => true,
+          'size' => 32
+          ));
+
+    echo mtrack_changeset($ent->rev, $repo);
+    foreach ($ent->branches as $b) {
+      echo " " . mtrack_branch($b);
+    }
+    foreach ($ent->tags as $t) {
+      echo " " . mtrack_tag($t);
+    }
+    echo "<br>\n";
+    echo mtrack_username($ent->changeby, array('no_image' => true)) . "<br>\n";
+    echo "$time <span class='time'>" . mtrack_date($ent->ctime) . "</span>\n";
+    echo "</div></div>\n";
+  }
+  echo "</div>\n";
+}
+
+
+mtrack_foot();
+
diff --git a/web/markitup-preview.php b/web/markitup-preview.php
new file mode 100644 (file)
index 0000000..0f8a728
--- /dev/null
@@ -0,0 +1,9 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+mtrack_head('preview', false);
+echo MTrackWiki::format_to_html($_POST['data']);
+mtrack_foot(false);
+
diff --git a/web/milestone.php b/web/milestone.php
new file mode 100644 (file)
index 0000000..7a1eac4
--- /dev/null
@@ -0,0 +1,307 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+$pi = urldecode(mtrack_get_pathinfo());
+
+function parse_date_string($str)
+{
+  if (!strlen($str)) {
+    return null;
+  }
+  return MTrackDB::unixtime(strtotime($str));
+}
+
+if ($_GET['new'] == 1 || $_GET['edit'] == 1) {
+  $error = null;
+
+  if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+    if (isset($_POST['cancel'])) {
+      header("Location: {$ABSWEB}roadmap.php");
+      exit;
+    }
+
+    if ($_GET['new'] == 1) {
+      MTrackACL::requireAllRights("Roadmap", 'create');
+      $ms = new MTrackMilestone;
+    } else {
+      MTrackACL::requireAllRights("Roadmap", 'modify');
+      $ms = MTrackMilestone::loadById($_POST['mid']);
+    }
+
+    if (strlen($_POST['name'])) {
+      $ms->name = $_POST['name'];
+      $ms->description = $_POST['desc'];
+
+      $pmid = (int)$_POST['pmid'];
+      if ($pmid > 0) {
+        $pm = MTrackMilestone::loadById($pmid);
+        if (!$pm) {
+          $error = "There is no milestone with a parent of $pmid";
+        } else {
+          $ms->pmid = $pmid;
+        }
+      } else {
+        $ms->pmid = null;
+      }
+
+      $ms->duedate = parse_date_string($_POST['duedate']);
+      $ms->startdate = parse_date_string($_POST['startdate']);
+
+      $compdate = parse_date_string($_POST['compdate']);
+      if ($ms->completed === null && $compdate !== null) {
+        $description = "$ms->name completed";
+      } else {
+        $description = $ms->description;
+      }
+      $ms->completed = $compdate;
+
+      $other = MTrackMilestone::loadByName($_POST['name']);
+      if ($other && ($_GET['new'] == 1 || $ms->mid != $other->mid)) {
+        $error = "a milestone named \"$ms->name\" already exists";
+      } else if ($error === null) {
+        $CS = MTrackChangeset::begin("milestone:$ms->name", $description);
+        $ms->save($CS);
+
+        if ($pmid < 1 && $_POST['additers'] == 'on') {
+          /* add children for iterations (not allowed for milestones
+           * that are themselves a child of another */
+          $start = strtotime($ms->startdate);
+          $end = strtotime($ms->duedate);
+          $days = (int)$_POST['iterduration'];
+
+          $n = 1;
+          $link = rawurlencode($ms->name);
+          while ($start < $end) {
+            $kid = new MTrackMilestone;
+            $kid->name = $ms->name . " ($n)";
+            $kid->description = "Iteration $n of [milestone:$link]";
+            $kid->startdate = MTrackDB::unixtime($start);
+            $due = strtotime("+$days day", $start);
+            if ($due > $end) {
+              $due = $end;
+            }
+            $kid->duedate = MTrackDB::unixtime($due);
+            $kid->pmid = $ms->mid;
+
+            $kid->save($CS);
+
+            $start = strtotime("+1 day", $due);
+            $n++;
+          }
+        }
+
+        if ($ms->completed !== null && $_POST['compmilestone'] != '') {
+          $TM = MTrackMilestone::loadById($_POST['compmilestone']);
+          foreach (MTrackDB::q("select t.tid from ticket_milestones tm left join tickets t on (tm.tid = t.tid) where mid = ? and status != 'closed'", $ms->mid)->fetchAll(PDO::FETCH_COLUMN, 0) as $tid) {
+            $T = MTrackIssue::loadById($tid);
+            $T->dissocMilestone($ms);
+            $T->assocMilestone($TM);
+            $T->addComment("$ms->name completed, moving ticket to $TM->name");
+            $T->save($CS);
+          }
+        }
+
+        $CS->commit();
+        header("Location: {$ABSWEB}milestone.php/$ms->name");
+        exit;
+      }
+    }
+    var_export($_POST);
+  } else if (strlen($pi)) {
+    MTrackACL::requireAllRights("Roadmap", 'modify');
+    $ms = MTrackMilestone::loadByName($pi);
+  } else {
+    MTrackACL::requireAllRights("Roadmap", 'create');
+    $ms = new MTrackMilestone;
+  }
+  mtrack_head($_GET['new'] == 1 ? "New Milestone" : "Edit Milestone");
+
+  if ($error) {
+    $error = htmlentities($error, ENT_QUOTES, 'utf-8');
+    echo <<<HTML
+<div class='ui-state-error ui-corner-all'>
+    <span class='ui-icon ui-icon-alert'></span> $error
+</div>
+
+HTML;
+  }
+
+  $name = htmlentities($ms->name, ENT_COMPAT, 'utf-8');
+  $desc = htmlentities($ms->description, ENT_COMPAT, 'utf-8');
+  
+  if ($ms->duedate) {
+    $duedate = date('m/d/y', strtotime($ms->duedate));
+  } else {
+    $duedate = '';
+  }
+  if ($ms->startdate) {
+    $startdate = date('m/d/y', strtotime($ms->startdate));
+  } else {
+    $startdate = '';
+  }
+
+  if ($ms->completed != null) {
+    $compdate = date('m/d/y', strtotime($ms->completed));
+  } else {
+    $compdate = null;
+  }
+
+  if ($_GET['new'] == 1) {
+    echo "<h1>New Milestone</h1>";
+    $save = 'Add';
+  } else {
+    echo "<h1>Edit Milestone</h1>";
+    $save = 'Save';
+  }
+
+  echo <<<HTML
+<form method='post'>
+<input type='hidden' name='mid' value='{$ms->mid}'>
+<div class='field'>
+  <label>Name of the milestone:</label><br>
+  <input type='text' id='name' name='name' size='32' value='$name'>
+</div>
+HTML;
+
+  $kids = MTrackDB::q('select name from milestones where pmid = ?', $ms->mid)->fetchAll(PDO::FETCH_COLUMN, 0);
+  if (count($kids)) {
+
+    echo <<<HTML
+<div class='field'>
+  <label>Children:</label> <em>Effort expended against the following milestones is also counted towards the burndown of this milestone</em><br>
+HTML;
+
+    foreach ($kids as $name) {
+      echo "<a href='{$ABSWEB}milestone.php/$name'>$name</a><br>\n";
+    }
+
+    echo "</div>\n";
+  
+  } else {
+
+    $parents = array();
+    foreach (MTrackDB::q('select mid, name from milestones where
+        pmid is null and ((deleted != 1 and mid != ? and completed is null)
+        or (mid = ?))
+        order by name',
+        $ms->mid, $ms->pmid)->fetchAll(PDO::FETCH_ASSOC) as $row) {
+      $parents[$row['mid']] = $row['name'];
+    }
+    $parents[''] = '(none)';
+    $parent = mtrack_select_box('pmid', $parents, $ms->pmid);
+
+
+    echo <<<HTML
+<div class='field'>
+  <label>Parent:</label> <em>Effort expended against a milestone is also counted towards the burndown of its parent</em><br>
+  $parent
+</div>
+HTML;
+  }
+
+  $open_milestones = MTrackMilestone::enumMilestones();
+  $open_milestones[''] = '(none)';
+
+  $compmilestone = mtrack_select_box('compmilestone', $open_milestones);
+
+  echo <<<HTML
+<fieldset>
+  <legend>Schedule</legend>
+  <div class='field'>
+    <label>Start:<br>
+      <input type='text' id='startdate' name='startdate' size='0'
+        value='$startdate' class='dateinput'>
+      <em>Format: MM/DD/YY</em>
+    </label>
+  </div>
+  <div class='field'>
+    <label>Due:<br>
+      <input type='text' id='duedate' name='duedate' size='0'
+        value='$duedate' class='dateinput'>
+      <em>Format: MM/DD/YY</em>
+    </label>
+  </div>
+  <br>
+  <div class='field'>
+    <label>
+      Completed:<br>
+      <input type='text' id='compdate' name='compdate'
+        size='0' value='$compdate' class='dateinput'>
+      <em>Format: MM/DD/YY</em>
+    </label><br>
+    <em>Re-target open tickets to milestone:</em> $compmilestone
+  </div>
+HTML;
+
+  if (count($kids) == 0 && !$ms->pmid) {
+    echo <<<HTML
+  <br>
+  <div class='field'>
+    <label>
+      <input type='checkbox' id='additers' name='additers'>
+      Add child milestones for iteration tracking<br>
+      <em>Iteration duration of
+      <input type='text' id='iterduration' name='iterduration'
+        size='3' value='7'>
+      days</em>
+    </label>
+  </div>
+HTML;
+  }
+
+  echo <<<HTML
+</fieldset>
+<div class='field'>
+  <fieldset class='iefix'>
+    <label for='desc'>Description</label><br/>
+    <em>By default, the milestone summary will display a burndown chart
+      as though you had added <tt>[[BurnDown(milestone=name,width=50%,height=150)]]</tt> into the description field below.<br>
+      If you wish to change the size and position of the chart, explicitly
+      enter the burndown macro in the description field.<br>
+      To turn off the burndown for this milestone, enter <tt>[[BurnDown()]]</tt> in the description field.
+    </em>
+    <textarea id='desc' name='desc' class='code wiki' rows='10' cols='78'>$desc</textarea>
+  </fieldset>
+</div>
+<div class='buttons'>
+  <button type='submit' name='save'>$save Milestone</button>
+  <button type='submit' name='cancel'>Cancel</button>
+</div>
+</form>
+<script type='text/javascript'>
+$(document).ready(function() {
+  $('#name').focus();
+  $('.dateinput').datepicker({
+    // minDate: 0,
+    dateFormat: 'mm/dd/yy'
+  });
+});
+</script>
+HTML;
+} else if (strlen($pi)) {
+
+  mtrack_head($pi);
+echo <<<HTML
+<div style="float:right">
+<button onclick="document.location.href='{$ABSWEB}milestone.php/$pi?edit=1';return false;">Edit Milestone</button>
+</div>
+HTML;
+
+  echo MTrackMilestone::macro_MilestoneSummary($pi);
+
+  $kids = MTrackDB::q('select name from milestones where pmid = 
+    (select mid from milestones where name = ?)', $pi)
+    ->fetchAll(PDO::FETCH_ASSOC);
+  if (count($kids)) {
+    echo "<h2>Related milestones:</h2>";
+    foreach ($kids as $row) {
+      echo MTrackMilestone::macro_MilestoneSummary($row['name']);
+    }
+  } 
+
+}  else {
+  throw new Exception("no such milestone $pi");
+}
+
+mtrack_foot();
diff --git a/web/mtrack.css b/web/mtrack.css
new file mode 100644 (file)
index 0000000..e36a0a2
--- /dev/null
@@ -0,0 +1,1377 @@
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, font, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td
+{
+  margin: 0;
+  padding: 0;
+  border: 0;
+  outline: 0;
+  font-size: 100%;
+  vertical-align: baseline;
+  background: transparent;
+}
+
+td, th {
+  padding-left: 0.25em;
+  padding-right: 0.25em;
+  text-align: left;
+}
+
+body {
+  line-height: 1;
+}
+ol {
+  list-style: decimal;
+}
+ul {
+  list-style: disc;
+}
+li {
+  /*list-style-position: inside;*/
+  margin-left: 2.5em;
+}
+blockquote, q {
+  quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+  content: '';
+  content: none;
+}
+
+/* remember to highlight inserts somehow! */
+ins {
+  text-decoration: none;
+}
+del {
+  text-decoration: line-through;
+}
+
+/* tables still need 'cellspacing="0"' in the markup
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+*/
+
+body {
+  background: #fff;
+  color: #000;
+  margin: 0;
+  padding: 0;
+}
+
+body, th, td {
+  font: normal 10pt
+   /* Cambria, */
+    'Lucida Grande', 'Lucida Sans Unicode',
+      Verdana, Arial, 'Bitstream Vera Sans',
+      Helvetica, sans-serif;
+}
+
+h1, h2, h3, h4, h5, h6 {
+  font-family: 'Trebuchet MS', verdana, sans-serif;
+  font-weight: bold;
+  margin: 0.75em 1em 0.75em 0;
+  letter-spacing: -0.018em;
+  page-break-after: avoid;
+}
+
+h1 {
+  font-size: 18pt;
+}
+
+h2 { font-size: 14pt; }
+h3 { font-size: 12pt; }
+
+hr {
+  border: none;
+  border-top: 1px solid #ccb;
+  margin: 2em 0;
+}
+
+address {
+  font-style: normal;
+}
+img {
+  border: none;
+}
+
+ol.wikilist li, ul.wikilist li {
+  width: 50em;
+}
+
+.underline { text-decoration: underline; }
+ol.loweralpha { list-style-type: lower-alpha; }
+ol.upperalpha { list-style-type: upper-alpha; }
+ol.lowerroman { list-style-type: lower-roman; }
+ol.upperroman { list-style-type: upper-roman; }
+ol.arabic { list-style-type: decimal; }
+
+:link, :visited {
+  text-decoration: none;
+  /* color: #b00; */
+  color: rgb(43, 84, 125);
+  border-bottom: 1px dotted #bbb;
+}
+
+:link:hover, :visited:hover {
+  background-color: #eee;
+  color: #555;
+}
+
+h1 :link, h1 :visited,
+h2 :link, h2 :visited,
+h3 :link, h3 :visited,
+h4 :link, h4 :visited,
+h5 :link, h5 :visited,
+h6 :link, h6 :visited {
+  color: inherit;
+}
+
+.anchor:link, .anchor:visited {
+  border: none;
+  color: #d7d7d7;
+  font-size: 0.8em;
+  vertical-align: text-top;
+}
+
+* > .anchor:link, * > .anchor:visited {
+  visibility: hidden;
+}
+
+h1:hover .anchor,
+h2:hover .anchor,
+h3:hover .anchor,
+h4:hover .anchor,
+h5:hover .anchor,
+h6:hover .anchor {
+  visibility: visible;
+}
+
+.nav h2, .nav hr {
+  display: none;
+}
+
+.nav ul {
+  font-size: 9pt;
+  list-style: none;
+  margin: 0;
+  text-align: right;
+}
+.nav li {
+  border-right: 1px solid #d7d7d7;
+  display: inline;
+  padding: 0 0.75em;
+  white-space: nowrap;
+}
+.nav li.last {
+  border-right: none;
+}
+
+#wikinav {
+  float: right;
+  clear: both;
+}
+
+#wikinav ul {
+  text-align: left;
+}
+
+#wikinav li {
+  display: block;
+  padding: 0.25em 0px;
+  margin-left: 0.5em;
+  border: none;
+}
+
+#mainnav {
+  font-size: 9pt;
+  margin: 1em 0 0.33em;
+  padding: 0.2em 0;
+}
+
+#mainnav li {
+  padding: 0.25em 0px;
+  margin-left: 0.5em;
+  border: none;
+}
+#mainnav li a {
+  background: #eee;
+}
+
+#mainnav :link, #mainnav :visited {
+  color: #000;
+  padding: 0.2em 1em;
+  border: 1px solid lightGrey;
+  border-radius: 8px;
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+}
+
+#mainnav :link:hover, #mainnav :visited:hover {
+  background-color: #ccc;
+}
+
+#mainnav .active :link, #mainnav .active :visited {
+  border: 1px solid #777;
+  background: #333;
+  color: #eee;
+  font-weight: bold;
+}
+
+#content {
+  margin: 1em;
+}
+#content p {
+  width: 50em;
+  margin-top: 1em;
+}
+
+#content td p:first-child {
+  margin-top: 0;
+}
+
+input, textarea, select {
+  margin: 2px;
+}
+input, select {
+  vertical-align: middle;
+}
+.button, input[type=button], input[type=submit], input[type=reset] {
+  background: #eee;
+  color: #222;
+  border: 1px outset #ccc;
+  padding: 0.1em 0.5em;
+}
+.button:hover, input[type=button]:hover, input[type=submit]:hover, input[type=reset]:hover {
+  background: #ccb;
+}
+.button[disabled], input[type=button][disabled], input[type=submit][disabled],
+input[type=reset][disabled] {
+ background: #f6f6f6;
+ border-style: solid;
+ color: #999;
+}
+input[type=text], input.textwidget, textarea { border: 1px solid #d7d7d7 }
+input[type=text], input.textwidget { padding: .25em .5em }
+input[type=text]:focus, input.textwidget:focus, textarea:focus {
+ border: 1px solid #886;
+}
+option { border-bottom: 1px dotted #d7d7d7 }
+fieldset { border: 1px solid #d7d7d7; padding: .5em; margin: 1em 0 }
+form p.hint, form span.hint { color: #666; font-size: 85%; font-style: italic; margin: .5em 0;
+  padding-left: 1em;
+}
+fieldset.iefix {
+  background: transparent;
+  border: none;
+  padding: 0;
+  margin: 0;
+}
+* html fieldset.iefix { width: 98% }
+fieldset.iefix p { margin: 0 }
+legend { color: #999; padding: 0 .25em; font-size: 90%; font-weight: bold }
+label.disabled { color: #d7d7d7 }
+.buttons { margin: .5em .5em .5em 0 }
+.buttons form, .buttons form div { display: inline }
+.buttons input { margin: 1em .5em .1em 0 }
+.inlinebuttons input {
+ font-size: 70%;
+ border-width: 1px;
+ border-style: dotted;
+ margin: 0 .1em;
+ padding: 0.1em;
+ background: none;
+}
+
+div.wikipreview {
+  background-color: #eee;
+  margin-bottom: 2em;
+}
+
+div.error, textarea.error, input.error {
+  border: solid 1px red;
+}
+
+div#motd {
+  float: right;
+}
+
+table.history {
+  border-collapse: collapse;
+}
+
+table.history tr {
+  vertical-align: text-top;
+}
+table.history tr th {
+  text-align: left;
+}
+
+table.history tr td.diff {
+  border-bottom: solid 1px #bbb;
+  padding: 1em 4em;
+}
+
+table.codeann {
+  border-collapse: collapse;
+  border: solid 1px #bbb;
+}
+
+tt, pre, td.code {
+  font-family: 'Consolas', 'Bitstream Vera Sans Mono',
+    'Monaco', 'Courier New', 'Courier', monospace;
+}
+
+pre, td.code {
+  border: solid 1px #bbb;
+  padding: 0.2em;
+  margin: 0.4em;
+  background-color: #eee;
+  overflow-x: auto;
+  word-wrap: break-word;
+}
+
+td.code {
+  overflow-x: auto;
+  word-wrap: break-word;
+  border-top: none;
+  border-bottom: none;
+  border-left: solid 1px #bbb;
+  border-right: solid 1px #bbb;
+  font-size: 9pt;
+}
+
+td.source-code {
+  overflow-x: auto;
+  white-space: pre;
+  font-family: 'Consolas', 'Bitstream Vera Sans Mono',
+    'Monaco', 'Courier New', 'Courier', monospace;
+  word-wrap: break-word;
+  line-height: 1.6em;
+  border: solid 1px #bbb;
+  background-color: #eee;
+}
+
+table.codeann {
+  width: 100%;
+}
+
+table.codeann tr th {
+  border-bottom: solid 1px #bbb;
+}
+table.codeann tr th.code {
+  text-align: left;
+}
+table.codeann tr td {
+  font-family: 'Consolas', 'Bitstream Vera Sans Mono',
+    'Monaco', 'Courier New', 'Courier', monospace;
+  font-size: 10pt
+}
+
+table.codeann tr th, table.codeann tr td {
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+}
+
+table.codeann tr th.line, table.codeann tr td.line,
+  table.codeann tr th.changeset, table.codeann tr td.changeset {
+  text-align: right;
+  line-height: 1.6em;
+}
+
+table.codeann tr td.line a:link {
+  text-decoration: none;
+  border: none;
+  padding: none;
+  margin: none;
+}
+
+table.codeann tr th.user, table.codeann tr td.user {
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+}
+
+/* by default, when showing an annotated file, hide annotations, but show
+ * the line numbers */
+table.codeann tr th.changeset, table.codeann tr td.changeset,
+table.codeann tr th.user, table.codeann tr td.user {
+  display: none;
+}
+
+pre {
+  font-size: 9pt;
+}
+
+#ticketinfo label {
+  font-weight: bold;
+  white-space: nowrap;
+}
+
+#ticketinfo fieldset {
+  width: 57em;
+}
+
+#ticketinfo fieldset#readonly-tkt-properties {
+  float: left;
+  min-height: 6em;
+  margin-right: 1em;
+  width: 35em;
+}
+
+#ticketinfo fieldset#readonly-tkt-resources {
+  min-height: 6em;
+  width: 20em;
+}
+
+div#readonly-tkt-description {
+  clear: both;
+  border: solid 1px #ccc;
+  min-width: 57em;
+  background-color: #ffc;
+  padding: 0.25em 0.5em 0.5em 0.5em;
+}
+
+div.ticketevent {
+  margin-top: 1.5em;
+  margin-bottom: 0.5em;
+  border-bottom: solid 1px #bbb;
+  color: #999;
+}
+
+blockquote.citation {
+  border-left: solid 2px #b00;
+  padding-left: 0.5em;
+  margin-left: inherit;
+  font-style: italic;
+  color: #444;
+}
+
+a.pmark {
+  color: inherit;
+}
+
+table.wiki {
+  border-collapse: collapse;
+}
+
+table.wiki tr td {
+  border: solid 1px #bbb;
+  padding: 0.2em 0.5em;
+}
+
+table.report {
+  border-collapse: collapse;
+  width: 100%;
+  clear: both;
+}
+
+table.report tr th {
+  border: solid 1px #bbb;
+  padding: 0.2em 0.5em;
+  font-weight: bold;
+  text-align: left;
+}
+
+table.report tr td {
+  border: solid 1px #bbb;
+  padding: 0.2em 0.5em;
+}
+
+h2.reportgroup {
+}
+
+table.progress {
+  width: 20em;
+  height: 1em;
+  border: solid 1px green;
+  border-collapse: collapse;
+}
+
+table.progress tr td.closed {
+  background-color: #8b8;
+  padding: 0;
+}
+
+table.esthours {
+  border: solid 1px blue;
+}
+
+table.esthours tr td.closed {
+  background-color: #88b;
+}
+
+div.milestone {
+  margin-bottom: 3em;
+}
+
+dt {
+  font-weight: bold;
+}
+
+dd {
+  margin-bottom: 1.2em;
+}
+
+textarea.code {
+  font-family: monospace;
+  font-size: 1em;
+}
+
+textarea.wiki {
+  width: 100%;
+  font-family: monospace;
+  font-size: 1em;
+}
+
+#banner-back {
+  min-height: 3em;
+  background-image: url(images/gradient-header.png);
+  background-repeat: repeat-x;
+  background-position: center bottom;
+  background-color: rgb(229,229,229);
+  border-bottom: 1px solid #eee;
+  padding: 0.5em 1em 0.5em 1em;
+}
+
+#banner {
+  display: inline;
+  font-family: Calibri, Arial, Verdana, 'Bitstream Vera Sans',
+    Helvetica, sans-serif;
+  font-weight: bolder;
+  font-size: 1.1em;
+  clear: both;
+}
+
+#header {
+  clear: both;
+}
+
+#mainsearch {
+  float: right;
+  margin: 0;
+}
+
+#mainsearch input.search {
+  margin-left: 1em;
+  width: 30em;
+}
+
+/* approximate Safari input type=search */
+input.roundsearch {
+  border-radius: 12px;
+  -moz-border-radius: 12px;
+  border-style: inset;
+  border-width: 1px;
+  border-color: #777;
+  color: black;
+  padding-left: 1em;
+  padding-right: 1em;
+}
+
+input.watermark {
+  color: #999;
+}
+
+
+div.excerpt span.hl {
+  background-color: yellow;
+}
+
+table.searchresults tr td {
+  padding-bottom: 0.8em;
+}
+
+table.searchresults tr {
+  vertical-align: top;
+}
+
+div.flotgraph div {
+}
+
+h1.timelineday {
+  font-size: 1.2em;
+  margin-top: 1.5em;
+  margin-bottom: 0.5em;
+  border-bottom: solid 1px #bbb;
+  color: #999;
+}
+
+div.timelineevent {
+  padding-bottom: 0.25em;
+  margin-top: 0.5em;
+  width: 100%;
+  overflow: hidden;
+  border-bottom: solid 1px #eee;
+}
+
+div.timelineevent a.userlink.timelineface {
+  float: left;
+  margin: 0 1em 1em 0;
+/*  padding: 0 1em 1em 0; */
+  border-bottom-style: none;
+}
+
+div.timelineevent span.time {
+  color: #999;
+  font-size: 0.9em;
+}
+
+div.timelinetext {
+  padding-left: 64px;
+  width: 50em;
+  padding-bottom: 0.5em;
+}
+
+div.timelinereason {
+  color: rgb(84,84,84);
+  font-size: 0.8em;
+  margin-bottom: 1em;
+}
+
+
+
+.newticket {
+  background: url(images/newticket.png) no-repeat;
+}
+.editticket {
+  background: url(images/editedticket.png) no-repeat;
+}
+.closedticket {
+  background: url(images/closedticket.png) no-repeat;
+}
+.editwiki {
+  background: url(images/wiki.png) no-repeat;
+}
+.editmilestone {
+  background: url(images/milestone.png) no-repeat;
+}
+.newchangeset {
+  background: url(images/changeset.png) no-repeat;
+}
+
+a.changesetlink {
+  background: rgb(170, 255, 170);
+  border-bottom: 1px solid rgb(0, 204, 51);
+  border-left: 1px solid rgb(204, 255, 204);
+  border-right: 1px solid rgb(0, 204, 51);
+  border-top: 1px solid rgb(204, 255, 204);
+  color: black;
+  white-space: normal;
+  padding-top: 1px;
+  padding-bottom: 1px;
+  padding-left: 2px;
+  padding-right: 2px;
+  font-size: 0.9em;
+  border-radius: 8px;
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+
+  background-image: url(images/changeset.png);
+  background-repeat: no-repeat;
+  background-position-y: 2;
+  padding-left: 14px;
+}
+
+table.codeann tr td.changeset a.changesetlink {
+  background-image: none;
+  padding-left: 2px;
+  font-size: 0.8em;
+}
+
+a.ticketlink {
+  background: rgb(255, 255, 170);
+  border-bottom: 1px solid rgb(255, 238, 0);
+  border-left: 1px solid rgb(255, 255, 204);
+  border-right: 1px solid rgb(255, 238, 0);
+  border-top: 1px solid rgb(255, 255, 204);
+  color: black;
+  white-space: normal;
+  padding-top: 1px;
+  padding-bottom: 1px;
+  padding-left: 2px;
+  padding-right: 2px;
+  font-size: 0.9em;
+
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+  border-radius: 8px;
+}
+
+a.wikilink {
+  background: rgb(170, 255, 170);
+  border-bottom: 1px solid rgb(0, 204, 51);
+  border-left: 1px solid rgb(204, 255, 204);
+  border-right: 1px solid rgb(0, 204, 51);
+  border-top: 1px solid rgb(204, 255, 204);
+  color: black;
+  white-space: normal;
+  padding-top: 1px;
+  padding-bottom: 1px;
+  padding-left: 2px;
+  padding-right: 2px;
+  font-size: 0.9em;
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+  border-radius: 8px;
+
+  background-image: url(images/wiki.png);
+  background-repeat: no-repeat;
+  background-position-y: 2;
+  background-position-x: 2;
+  padding-left: 16px;
+}
+/* Styles for tabular listings such as those used for displaying directory
+   contents and report results. */
+table.listing {
+ clear: both;
+ border-bottom: 1px solid #d7d7d7;
+ border-collapse: collapse;
+ border-spacing: 0;
+ margin-top: 1em;
+ width: 100%;
+}
+table.listing th { text-align: left; padding: 0 1em .1em 0; font-size: 12px }
+table.listing thead { background: #f7f7f0 }
+table.listing thead th {
+ border: 1px solid #d7d7d7;
+ border-bottom-color: #999;
+ font-size: 11px;
+ font-weight: bold;
+ padding: 2px .5em;
+ vertical-align: bottom;
+}
+table.listing thead th :link:hover, table.listing thead th :visited:hover {
+ background-color: transparent;
+}
+table.listing thead th a { border: none; padding-right: 12px }
+table.listing th.asc a, table.listing th.desc a { font-weight: bold }
+table.listing th.asc a, table.listing th.desc a {
+ background-position: 100% 50%;
+ background-repeat: no-repeat;
+}
+table.listing th.asc a { }
+table.listing th.desc a { }
+table.listing tbody td, table.listing tbody th {
+ border: 1px dotted #ddd;
+ padding: .33em .5em;
+ vertical-align: top;
+}
+table.listing tbody td a:hover, table.listing tbody th a:hover {
+ background-color: transparent;
+}
+table.listing tbody tr { border-top: 1px solid #ddd }
+table.listing tbody tr.even { background-color: #fcfcfc }
+table.listing tbody tr.odd { background-color: #f7f7f7 }
+table.listing tbody tr:hover { background: #eed !important }
+
+
+/* Styles for the directory entries table
+   (extends the styles for "table.listing") */
+#dirlist { margin-top: 0 }
+#dirlist td.rev, #dirlist td.age, #dirlist td.change, #dirlist td.size {
+ color: #888;
+ white-space: nowrap;
+}
+#dirlist td.size { text-align: right; }
+/* #dirlist td.name { width: 30% } */
+#dirlist td.name a, #dirlist td.name span {
+ background-position: 0% 50%;
+ background-repeat: no-repeat;
+ padding-left: 20px;
+}
+#dirlist td.name a.parent { background-image: url(images/parent.png) }
+#dirlist td.name a.dir { background-image: url(images/treeview/folder-closed.gif) }
+#dirlist td.name span.dir { background-image: url(images/treeview/folder-closed.gif) }
+#dirlist td.name a.file { background-image: url(images/file.png) }
+#dirlist td.name span.file { background-image: url(images/filedeny.png) }
+#dirlist td.name a, #dirlist td.rev a { border-bottom: none; display: block }
+#dirlist td.change {
+  white-space: normal;
+}
+
+table.code.diff {
+  border-collapse: collapse;
+  border: 1px solid #d3d3d0;
+  width: 100%;
+  margin-bottom: 3em;
+}
+
+table.code.diff tr.removed {
+  background-color: rgb(255, 221, 221);
+}
+table.code.diff tr.added {
+  background-color: rgb(221, 255, 221);
+}
+
+table.code.diff tr td {
+  white-space: pre;
+  font-family: 'Consolas', 'Bitstream Vera Sans Mono',
+    'Monaco', 'Courier New', 'Courier', monospace;
+  color: black;
+  line-height: 1.5em;
+  overflow-x: auto;
+  word-wrap: break-word;
+}
+
+table.code.diff tr td.line {
+  padding-left: 1.5em;
+  border-left: 1px solid #d3d3d0;
+}
+table.code.diff tr.first {
+  border-top: 1px solid #d3d3d0;
+}
+
+table.code.diff tr td.lineno {
+  text-align: right;
+  padding-right: 1em;
+  width: 1%;
+}
+
+table.code.diff tr.meta td.line {
+  border: none;
+}
+
+pre.code.diff {
+  border: 1px solid #d3d3d0;
+  color: #939399;
+}
+
+.code.diff code {
+  width: 100%;
+  margin: 0;
+  padding: 0;
+  color: black;
+  line-height: 1.5em;
+  display: inline-block;
+  overflow-x: auto;
+  word-wrap: break-word;
+}
+
+/*
+.code.diff code.odd {
+  background-color: #f7f7f7;
+}
+.code.diff code.even {
+  background-color: #fcfcfc;
+}
+.code.diff code.removed {
+  background-color: rgb(255, 221, 221);
+}
+.code.diff code.added {
+  background-color: rgb(221, 255, 221);
+}
+*/
+
+ol.code {
+  text-align: left;
+  border: 1px solid #d3d3d0;
+  color: #939399;
+}
+
+ol.code li {
+  white-space: nowrap;
+  list-style-type: none;
+  margin: 0;
+  margin-left: 2em;
+  padding: 0 0 0 0em;
+  text-align: left;
+  border-left: 1px solid #d3d3d0;
+}
+
+ol.code li.even { background-color: #fcfcfc }
+ol.code li.odd { background-color: #f7f7f7 }
+ol.code li code {
+  white-space: pre;
+  padding-left: 1em;
+  line-height: 1.5em;
+  color: black;
+}
+ol.code {
+  overflow-x: auto;
+  word-wrap: break-word;
+}
+div.changesets {
+  background-color: #ddd;
+}
+
+div.changeset, div.changesetodd {
+  margin: 0;
+  padding: 0;
+}
+
+div.changesetodd {
+  background-color: #eee;
+}
+
+div.changesets img.gravatar, div.revinfo img.gravatar
+{
+  float: left;
+  margin: 0 0.5em 0.5em 0;
+}
+
+div.changelog {
+  padding: 0.5em;
+  padding-left: 1em;
+}
+
+div.changeinfo {
+  border-bottom: solid 1px #bbb;
+  margin: 0;
+  margin-top: 1em;
+  padding: 0;
+  padding-bottom: 1.5em;
+  padding-left: 1em;
+}
+
+div.changesetday {
+  font-size: 1.2em;
+  background-color: #ccc;
+  border-bottom: solid 1px #bbb;
+  margin: 0;
+  padding: 0;
+  padding-bottom: 0.3em;
+  padding-top: 0.3em;
+  padding-left: 1em;
+  color: #777;
+}
+
+span.branchname, span.milestone {
+  background: rgb(170, 255, 170);
+  border-bottom: 1px solid rgb(0, 204, 51);
+  border-left: 1px solid rgb(204, 255, 204);
+  border-right: 1px solid rgb(0, 204, 51);
+  border-top: 1px solid rgb(204, 255, 204);
+  color: black;
+  white-space: normal;
+  padding-top: 1px;
+  padding-bottom: 1px;
+  padding-left: 2px;
+  padding-right: 2px;
+  font-size: 0.9em;
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+  border-radius: 8px;
+}
+
+a.keyword {
+  background: rgb(170, 255, 170);
+  border-bottom: 1px solid rgb(0, 204, 51);
+  border-left: 1px solid rgb(204, 255, 204);
+  border-right: 1px solid rgb(0, 204, 51);
+  border-top: 1px solid rgb(204, 255, 204);
+  color: black;
+  white-space: normal;
+  padding-top: 1px;
+  padding-bottom: 1px;
+  padding-left: 4px;
+  padding-right: 4px;
+  margin-right: 0.5em;
+  font-size: 0.9em;
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+  border-radius: 8px;
+}
+
+span.milestone {
+  background-image: url(images/milestone.png);
+  background-repeat: no-repeat;
+  background-position-x: 2px;
+  padding-left: 16px;
+}
+
+span.tagname {
+  background: rgb(255, 255, 170);
+  border-bottom: 1px solid rgb(255, 238, 0);
+  border-left: 1px solid rgb(255, 255, 204);
+  border-right: 1px solid rgb(255, 238, 0);
+  border-top: 1px solid rgb(255, 255, 204);
+  color: black;
+  white-space: normal;
+  padding-top: 1px;
+  padding-bottom: 1px;
+  padding-left: 2px;
+  padding-right: 2px;
+  font-size: 0.9em;
+}
+
+.completed {
+  text-decoration: line-through;
+}
+
+ol.code li code.removed {
+  background-color: rgb(255, 221, 221);
+  display: block;
+  padding-right: 0;
+}
+
+ol.code li code.added {
+  background-color: rgb(221, 255, 221);
+  display: block;
+  padding-right: 0;
+}
+
+div.difffiles {
+  margin-top: 2em;
+  margin-bottom: 1em;
+}
+
+table.fields {
+  width: 45em;
+}
+
+input.summaryedit {
+  font-size: 1.1em;
+  font-weight: bold;
+}
+
+select.asmSelect {
+  display: inline;
+}
+
+div.asmContainer {
+ border: 1px solid #d7d7d7;
+ padding: .5em;
+}
+
+/* disabled options in the asmSelect */
+select.asmSelect option.asmOptionDisabled {
+  color: #999;
+}
+
+ol.asmList, ul.asmList {
+  /* html list constructed around selected items */
+  margin: 0.25em 0 1em 1em;
+  position: relative;
+  display: block;
+  padding: 0.4em 0 0 0;
+}
+
+li.asmListItem {
+  margin-left: 0.5em;
+  line-height: 1.6em;
+  list-style: disc;
+}
+
+a.asmListItemRemove {
+  padding: 0.2em 0.2em 0.2em 0.5em;
+  border: none;
+}
+
+div.ticketchangeinfo img.gravatar {
+  float: right;
+  margin: 0 0em 1em 1em;
+}
+div.timelinereason img.gravatar {
+  float: left;
+  margin: 0 1em 1em 0em;
+}
+div.ticketevent, div.timelineevent, h1.timelineday {
+  clear: both;
+}
+
+div.userinfo img.gravatar {
+  float: right;
+}
+
+div.ui-state-highlight, div.ui-state-error {
+  padding: 0.4em;
+}
+.ui-widget {
+  font-size: 1em;
+}
+.ui-widget p {
+  margin-bottom: 1.2em;
+}
+
+span.ui-icon-info, span.ui-icon-alert {
+  margin-right: 0.6em;
+  float: left;
+}
+
+#changelog-container {
+  margin-left: 2em;
+}
+
+div.attachment-list {
+  clear: both;
+  margin-top: 1em;
+  margin-bottom: 1em;
+  padding: 0;
+}
+
+/* sortable report tables */
+
+table.report thead tr th {
+  cursor: pointer;
+/*  font-size: 0.7em;*/
+}
+
+table.report thead tr .header {
+  background-image: url(images/sort/bg.gif);
+  background-repeat: no-repeat;
+  background-position: center right;
+  padding-right: 16px;
+}
+
+table.report thead tr .headerSortUp {
+  background-image: url(images/sort/asc.gif);
+}
+
+table.report thead tr .headerSortDown {
+  background-image: url(images/sort/desc.gif);
+}
+
+table.report tbody tr.statusclosed td {
+  background-color: #eee !important;
+  color: #777;
+}
+
+table.report tbody tr.statusclosed td.summary,
+    table.report tbody tr.statusclosed td.ticket {
+  text-decoration: line-through;
+}
+
+table.report tbody tr.color1 td {
+  background-color: rgb(255, 221, 204);
+  color: rgb(170, 34, 34);
+}
+table.report tbody tr.color2 td {
+  background-color: rgb(255, 255, 187);
+  color: rgb(136, 136, 0);
+}
+table.report tbody tr.color4 td {
+  background-color: rgb(221, 255, 255);
+  color: rgb(0, 153, 153);
+}
+
+table.report tbody tr.color5 td {
+  background-color: rgb(221, 231, 255);
+  color: rgb(68, 102, 153);
+}
+
+table.report tbody tr td a {
+  border-bottom-style: none;
+  color: inherit;
+}
+
+table.report tbody tr td {
+  white-space: nowrap;
+ /* font-size: 0.75em; */
+}
+
+table.report tbody tr td.summary {
+  white-space: normal;
+}
+
+.treeview, .treeview ul {
+  padding: 0;
+  margin: 0;
+  list-style: none;
+}
+
+.treeview ul {
+  margin-top: 4px;
+}
+
+.treeview .hitarea {
+/*  background: url(images/treeview/treeview-gray.gif) -64px -25px no-repeat; */
+  height: 16px;
+  width: 16px;
+  margin-left: -16px;
+  float: left;
+  cursor: pointer;
+}
+
+/* fix for IE6 */
+* html .hitarea {
+  display: inline;
+  float: none;
+}
+.treeview li {
+  margin: 0;
+  padding: 3px 0pt 3px 20px;
+}
+
+.treeview li {
+  background-image: none;
+  /* url(images/treeview/treeview-gray-line.gif) 0 0 no-repeat; */
+}
+.treeview li.collapsable {
+  background: url(images/treeview/folder.gif) no-repeat;
+}
+.treeview li.expandable {
+  background: url(images/treeview/folder-closed.gif) no-repeat;
+}
+
+#wikilastchange {
+  float: right;
+  padding: 0.5em;
+  margin: 1em 0 1.5em 1.5em;
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+  border-radius: 8px;
+  border: solid 1px #bbb;
+  background-color: #eee;
+  font-size: 0.8em;
+  width: 15em;
+}
+
+#wikilastchange a.userlink.wikilastchange {
+  float: left;
+  padding: 0 0.5em 0.5em 0;
+  border: none;
+}
+
+#footer {
+  clear: both;
+  background-image: url(images/gradient-footer.png);
+  background-position: center top;
+  background-repeat: repeat-x;
+  background-color: rgb(229,229,229);
+  border-top: 1px solid #eee;
+  width: 100%;
+  margin-top: 1em;
+}
+
+div.navfoot {
+  color: #999;
+  font-size: 0.8em;
+  padding-left: 1em;
+  padding-top: 1em;
+  height: 2.5em;
+}
+
+#openid_identifier {
+  font-size: 2em;
+}
+
+#openid-sign-in {
+  font-size: 2em;
+}
+
+#openidlink {
+  border: none;
+}
+
+@media print {
+  #header, #banner, #mainsearch, div.navfoot {
+    display: none;
+  }
+  #qform {
+    margin-bottom: 0em;
+  }
+  #qtable tr td, #qtable tr td input {
+    font-size: 0.5em;
+  }
+  #qtable tr td select {
+    font-size: 1em;
+  }
+  input {
+    border: none;
+  }
+  input[type=submit], button, #customqryaddfilter {
+    display: none;
+  }
+  h1 {
+    font-size: 1.2em;
+  }
+  #wikinav, #wikilastchange {
+    display: none;
+  }
+}
+
+span.fulldate {
+  color: #999;
+  padding-left: 1.5em;
+  padding-right: 1.5em;
+}
+
+div.ticketevent abbr.timeinterval {
+  color: #777;
+}
+
+tr.inactiveuser {
+  text-decoration: line-through;
+}
+
+.button, button, input[type=button], input[type=submit], input[type=file] {
+  border-radius: 4px;
+  -webkit-border-radius: 4px;
+  -moz-border-radius: 4px;
+  background: #e6e6e6
+    url(css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png);
+  border: 1px solid lightGrey;
+  color: #555;
+  cursor: pointer;
+}
+
+.button:hover, button:hover, input[type=button]:hover, input[type=submit]:hover, input[type=file]:hover {
+  background: #dadada
+    url(css/smoothness/images/ui-bg_glass_75_dadada_1x400.png);
+  border: 1px solid #999;
+  color: #212121;
+}
+
+.button[disabled], button[disabled], input[type=button][disabled], input[type=submit][disabled], input[type=file][disabled] {
+  background: #fff;
+  border: 1px solid #ddd;
+  color: #999;
+}
+
+textarea.shortwiki.markItUpEditor {
+  height: 5em;
+}
+
+#snippetmug {
+  float: right;
+}
+
+#recentsnippets {
+  font-size: smaller;
+  width: 10em;
+  vertical-align: top;
+}
+
+form.snippetform,
+div.snippetview {
+}
+
+div.snippetsummary {
+  margin-top: 1em;
+}
+
+#newsnippet {
+  margin-bottom: 1em;
+}
+
+div.permissioneditor th {
+  font-weight: bold;
+}
+
+div.permissioneditor tr.inheritedacl {
+  color: #999;
+}
+
+div.button-float {
+  width: 100%;
+  background-color: white;
+  padding: 0.5em;
+}
+
+div.button-float-floating {
+  border: solid 1px #ccc;
+  left: -1em;
+  padding-left: 2.3em;
+}
+
+/* vim:ts=2:sw=2:et:
+ */
diff --git a/web/openid.php b/web/openid.php
new file mode 100644 (file)
index 0000000..7b0345d
--- /dev/null
@@ -0,0 +1,212 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+require_once 'Auth/OpenID/Consumer.php';
+require_once 'Auth/OpenID/FileStore.php';
+require_once 'Auth/OpenID/SReg.php';
+require_once 'Auth/OpenID/PAPE.php';
+
+$store_location = MTrackConfig::get('openid', 'store_dir');
+if (!$store_location) {
+  $store_location = MTrackConfig::get('core', 'vardir') . '/openid';
+}
+if (!is_dir($store_location)) {
+  mkdir($store_location);
+}
+$store = new Auth_OpenID_FileStore($store_location);
+$consumer = new Auth_OpenID_Consumer($store);
+
+$message = null;
+
+$pi = mtrack_get_pathinfo();
+if ($_SERVER['REQUEST_METHOD'] == 'POST' && $pi != 'register') {
+
+  $req = null;
+
+  if (!isset($_POST['openid_identifier']) ||
+      !strlen($_POST['openid_identifier'])) {
+    $message = "you must fill in your OpenID";
+  } else {
+    $id = $_POST['openid_identifier'];
+    if (!preg_match('/^https?:\/\//', $id)) {
+      $id = "http://$id";
+    }
+    $req = $consumer->begin($id);
+    if (!$req) {
+      $message = "not a valid OpenID";
+    }
+  }
+  if ($req) {
+    $sreg = Auth_OpenID_SRegRequest::build(
+      array('nickname', 'fullname', 'email')
+    );
+    $req->addExtension($sreg);
+
+    if ($req->shouldSendRedirect()) {
+      $rurl = $req->redirectURL(
+        $ABSWEB, $ABSWEB . 'openid.php/callback');
+      if (Auth_OpenID::isFailure($rurl)) {
+        $message = "Unable to redirect to server: " . $rurl->message;
+      } else {
+        header("Location: $rurl");
+        exit;
+      }
+    } else {
+      $html = $req->htmlMarkup($ABSWEB, $ABSWEB . 'openid.php/callback',
+        false, array('id' => 'openid_message'));
+      if (Auth_OpenID::isFailure($html)) {
+        $message = "Unable to redirect to server: " . $html->message;
+      } else {
+        echo $html;
+      }
+    }
+  }
+} else if ($pi == 'callback') {
+  $res = $consumer->complete($ABSWEB . 'openid.php/callback');
+
+  if ($res->status == Auth_OpenID_CANCEL) {
+    $message = 'Verification cancelled';
+  } else if ($res->status == Auth_OpenID_FAILURE) {
+    $message = 'OpenID authentication failed: ' . $res->message;
+  } else if ($res->status == Auth_OpenID_SUCCESS) {
+    $id = $res->getDisplayIdentifier();
+    $sreg = Auth_OpenID_SRegResponse::fromSuccessResponse($res)->contents();
+
+    if (!empty($sreg['nickname'])) {
+      $name = $sreg['nickname'];
+    } else if (!empty($sreg['fullname'])) {
+      $name = $sreg['fullname'];
+    } else {
+      $name = $id;
+    }
+    $message = 'Authenticated as ' . $name;
+
+    $_SESSION['openid.id'] = $id;
+    unset($_SESSION['openid.userid']);
+    $_SESSION['openid.name'] = $name;
+    if (!empty($sreg['email'])) {
+      $_SESSION['openid.email'] = $sreg['email'];
+    }
+    /* See if we can find a canonical identity for the user */
+    foreach (MTrackDB::q('select userid from useraliases where alias = ?',
+        $id)->fetchAll() as $row) {
+      $_SESSION['openid.userid'] = $row[0];
+      break;
+    }
+
+    if (!isset($_SESSION['openid.userid'])) {
+      /* no alias; is there a direct userinfo entry? */
+      foreach (MTrackDB::q('select userid from userinfo where userid = ?',
+          $id)->fetchAll() as $row) {
+        $_SERVER['openid.userid'] = $row[0];
+        break;
+      }
+    }
+
+    if (!isset($_SESSION['openid.userid'])) {
+      /* prompt the user to fill out some basic details so that we can create
+       * a local identity and associate their OpenID with it */
+      header("Location: {$ABSWEB}openid.php/register?" .
+        http_build_query($sreg));
+    } else {
+      header("Location: " . $ABSWEB);
+    }
+    exit;
+  } else {
+    $message = 'An error occurred while talking to your OpenID provider';
+  }
+} else if ($pi == 'signout') {
+  session_destroy();
+  header('Location: ' . $ABSWEB);
+  exit;
+} else if ($pi == 'register') {
+
+  if (!isset($_SESSION['openid.id'])) {
+    header("Location: " . $ABSWEB);
+    exit;
+  }
+
+  $userid = isset($_REQUEST['nickname']) ? $_REQUEST['nickname'] : '';
+  $email = isset($_REQUEST['email']) ? $_REQUEST['email'] : '';
+  $message = null;
+
+  /* See if we can find a canonical identity for the user */
+  foreach (MTrackDB::q('select userid from useraliases where alias = ?',
+      $_SESSION['openid.id'])->fetchAll() as $row) {
+    header("Location: " . $ABSWEB);
+    exit;
+  }
+
+  if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+    if (!strlen($userid)) {
+      $message = 'You must enter a userid';
+    } else {
+      /* is the requested id available? */
+      $avail = true;
+      foreach (MTrackDB::q('select userid from userinfo where userid = ?',
+            $userid)->fetchAll() as $row) {
+        $avail = false;
+        $message = "Your selected user ID is not available";
+      }
+      if ($avail) {
+        MTrackDB::q('insert into userinfo (userid, email, active) values (?, ?, 1)', $userid, $email);
+        /* we know the alias doesn't already exist, because we double-checked
+         * for it above */
+        MTrackDB::q('insert into useraliases (userid, alias) values (?,?)',
+          $userid, $_SESSION['openid.id']);
+        header("Location: {$ABSWEB}user.php?user=$userid&edit=1");
+        exit;
+      }
+    }
+  }
+
+  mtrack_head('Register');
+
+  $userid = htmlentities($userid, ENT_QUOTES, 'utf-8');
+  $email = htmlentities($email, ENT_QUOTES, 'utf-8');
+
+  if ($message) {
+    $message = htmlentities($message, ENT_QUOTES, 'utf-8');
+    echo <<<HTML
+<div class='ui-state-error ui-corner-all'>
+    <span class='ui-icon ui-icon-alert'></span>
+    $message
+</div>
+HTML;
+  }
+
+  echo <<<HTML
+<h1>Set up your local account</h1>
+<form method='post'>
+  User ID: <input type='text' name='nickname' value='$userid'><br>
+  Email: <input type='text' name='email' value='$email'><br>
+  <button type='submit'>Save</button>
+</form>
+
+
+HTML;
+  mtrack_foot();
+  exit;
+}
+
+mtrack_head('Authentication Required');
+echo "<h1>Please sign in with your <a id='openidlink' href='http://openid.net'><img src='{$ABSWEB}images/logo_openid.png' alt='OpenID' border='0'></a></h1>\n";
+echo "<form method='post' action='{$ABSWEB}openid.php'>";
+echo "<input type='text' name='openid_identifier' id='openid_identifier'>";
+echo " <button type='submit' id='openid-sign-in'>Sign In</button>";
+
+if ($message) {
+  $message = htmlentities($message, ENT_QUOTES, 'utf-8');
+  echo <<<HTML
+<div class='ui-state-highlight ui-corner-all'>
+    <span class='ui-icon ui-icon-info'></span>
+    $message
+</div>
+HTML;
+}
+
+echo "</form>";
+
+
+mtrack_foot();
+
diff --git a/web/query.php b/web/query.php
new file mode 100644 (file)
index 0000000..1f05a73
--- /dev/null
@@ -0,0 +1,328 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+mtrack_head("Custom Query");
+
+echo "<h1>Custom Query</h1>\n";
+
+/* This logic matches up to equivalent logic in the macro_RunReport
+ * function in inc/report */
+
+$params = array();
+
+if (strlen($_SERVER['QUERY_STRING'])) {
+  $qs = $_SERVER['QUERY_STRING'];
+} else {
+  $qs = mtrack_get_pathinfo();
+}
+
+list ($params, $mparams) = MTrackReport::parseQuery($qs);
+
+echo "<form action='{$ABSWEB}query.php' method='get' id='qform' onsubmit='return false;'>";
+echo "<table id='qtable'></table></form>";
+
+$params = json_encode($params);
+
+$milestones = json_encode(array_values(MTrackMilestone::enumMilestones(true)));
+echo <<<HTML
+<form id='customqryaddfilter'>
+Add Filter: <select id='addfilt'>
+<option value="">- Select to add a filter</option>
+HTML;
+
+$fields = array('cc', 'component', 'milestone', 'status', 'owner',
+  'type', 'summary', 'ticket', 'priority', 'keyword');
+
+asort($fields);
+$labels = array();
+
+foreach ($fields as $field) {
+  echo "<option value='$field'>" . ucfirst($field) . "</option>\n";
+  $labels[$field] = ucfirst($field);
+}
+$C = MTrackTicket_CustomFields::getInstance();
+$custom_fields = new stdclass;
+foreach ($C->fields as $f) {
+  echo "<option value='$f->name'>" .
+    htmlentities($f->label, ENT_QUOTES, 'utf-8') . "</option>\n";
+  $labels[$f->name] = $f->label;
+  if ($f->type == 'select' || $f->type == 'multiselect') {
+    $d = $f->ticketData();
+    $custom_fields->{$f->name} = array_values($d['options']);
+  }
+}
+echo <<<HTML
+</select>
+<br>
+<div id='colselector'>
+<h3><a href='#'>Choose Columns (drag to re-order)</a></h3>
+<div style="display:none">
+<ul id='columns'>
+HTML;
+
+$labels = json_encode($labels);
+$custom_fields = json_encode($custom_fields);
+$c = new MTrackClassification;
+$classifications = json_encode(array_values($c->enumerate()));
+$c = new MTrackPriority;
+$priorities = json_encode(array_values($c->enumerate()));
+/* Allow selection of columns */
+function add_col($name, $label) {
+  global $mparams;
+  $checked = in_array($name, $mparams['col']) ? ' checked="yes" ' : '';
+  $label = htmlentities($label, ENT_QUOTES, 'utf-8');
+  echo "<li class='ui-state-default'><input type='checkbox' name='col_$name' mtrackcol='$name' class='qrycol' $checked> ";
+  echo "<label for='col_$name'>$label</label></li> ";
+}
+$all_cols = array();
+
+// Add in the selected columns in order first
+foreach ($mparams['col'] as $col) {
+  $field = $C->fieldByName($col);
+  if ($field) {
+    $all_cols[$field->name] = $field->label;
+  } else {
+    $all_cols[$col] = ucfirst($col);
+  }
+}
+// Add in other possible fields
+foreach ($fields as $field) {
+  $all_cols[$field] = ucfirst($field);
+}
+$possible_fields = array(
+  'severity', 'remaining'
+);
+foreach ($possible_fields as $name) {
+  $all_cols[$name] = ucfirst($name);
+}
+foreach ($C->fields as $f) {
+  $all_cols[$f->name] = $f->label;
+}
+
+foreach ($all_cols as $name => $label) {
+  add_col($name, $label);
+}
+
+echo <<<HTML
+</ul>
+</div>
+</div>
+</form>
+<button id='updfilt'>Update</button><br>
+<script language='javascript' type='text/javascript'>
+var initq = $params;
+var milestones = $milestones;
+var classifications = $classifications;
+var priorities = $priorities;
+var next_field_id = 1;
+var adding_field = false;
+var custom_fields = $custom_fields;
+var field_labels = $labels;
+
+function mtrack_add_sel(sel, a, b)
+{
+  sel.options[sel.options.length] = new Option(a, b);
+}
+
+// given a field name, operator and value, create a new entry in the form
+function mtrack_add_field(name, op, value)
+{
+  var qtable = document.getElementById('qtable');
+
+  // <tr><td>X</td><td>name</td><td>op select</td><td>value</td></tr>
+  var tr = document.createElement('tr');
+  var xcell = document.createElement('td');
+  var but = document.createElement('button');
+  but.innerHTML = "X";
+  xcell.appendChild(but);
+  xcell.onclick = function() {
+    qtable.removeChild(tr);
+    return false;
+  };
+  tr.appendChild(xcell);
+
+  var ncell = document.createElement('td');
+  ncell.innerHTML = field_labels[name];
+  ncell.align = "right";
+  var ntype = document.createElement('input');
+  ntype.type = 'hidden';
+  ntype.id = "optyp_" + next_field_id;
+  ntype.name = ntype.id;
+  ntype.value = name;
+  ncell.appendChild(ntype);
+  tr.appendChild(ncell);
+
+  var opcell = document.createElement('td');
+  // create the operator map
+  var sel = document.createElement('select');
+  sel.id = "opsel_" + next_field_id;
+  sel.name = sel.id;
+  mtrack_add_sel(sel, "is", "=");
+  mtrack_add_sel(sel, "is not", "!=");
+
+  if (name != 'milestone' && name != 'status' && name != 'type') {
+    mtrack_add_sel(sel, "contains", "~=");
+    mtrack_add_sel(sel, "does not contain", "!~=");
+    mtrack_add_sel(sel, "starts with", "^=");
+    mtrack_add_sel(sel, "does not start with", "!^=");
+    mtrack_add_sel(sel, "ends with", "\$=");
+    mtrack_add_sel(sel, "does not end with", "!\$=");
+  }
+  var i;
+  for (i = 0; i < sel.length; i++) {
+    if (sel.options[i].value == op) {
+      sel.selectedIndex = i;
+      break;
+    }
+  }
+
+  opcell.appendChild(sel);
+  tr.appendChild(opcell);
+
+  var vid = "opval_" + next_field_id;
+
+  var vcell = document.createElement('td');
+  var vele = null;
+
+  if (name == 'milestone') {
+    vele = document.createElement('select');
+    for (i in milestones) {
+      mtrack_add_sel(vele, milestones[i], milestones[i]);
+      if (milestones[i] == value) {
+        vele.selectedIndex = vele.length - 1;
+      }
+    }
+  } else if (name == 'status') {
+    vele = document.createElement('select');
+    mtrack_add_sel(vele, 'new', 'new');
+    mtrack_add_sel(vele, 'open', 'open');
+    mtrack_add_sel(vele, 'closed', 'closed');
+    mtrack_add_sel(vele, 'assigned', 'assigned');
+    switch (value) {
+      case 'new': vele.selectedIndex = 0; break;
+      case 'open': vele.selectedIndex = 1; break;
+      case 'closed': vele.selectedIndex = 2; break;
+      case 'assigned': vele.selectedIndex = 3; break;
+    }
+  } else if (name == 'type') {
+    vele = document.createElement('select');
+    for (i in classifications) {
+      mtrack_add_sel(vele, classifications[i], classifications[i]);
+      if (classifications[i] == value) {
+        vele.selectedIndex = vele.length - 1;
+      }
+    }
+  } else if (name == 'priority') {
+    vele = document.createElement('select');
+    for (i in priorities) {
+      mtrack_add_sel(vele, priorities[i], priorities[i]);
+      if (priorities[i] == value) {
+        vele.selectedIndex = vele.length - 1;
+      }
+    }
+  } else if (custom_fields[name]) {
+    vele = document.createElement('select');
+    var opts = custom_fields[name];
+    for (i in opts) {
+      mtrack_add_sel(vele, opts[i], opts[i]);
+      if (opts[i] == value) {
+        vele.selectedIndex = vele.length - 1;
+      }
+    }
+  }
+
+  if (vele == null) {
+    // default to a text entry field
+    vele = document.createElement('input');
+    vele.type = 'text';
+    vele.value = value;
+  }
+  vele.name = vid;
+  vele.id = vid;
+  vcell.appendChild(vele);
+  tr.appendChild(vcell);
+
+  qtable.appendChild(tr);
+  \$(vele).bind('keypress', function (e) {
+    switch (e.keyCode) {
+      case $.ui.keyCode.ENTER:
+      case $.ui.keyCode.BACKSPACE:
+        return false;
+    }
+  });
+
+  next_field_id++;
+}
+
+$(document).ready(function (){
+  $('#colselector').accordion({
+    collapsible: true,
+    active: false
+  });
+  $('#columns').sortable();
+
+  // decode the parameters and build out the form
+  var prop;
+  for (prop in initq) {
+    var d = initq[prop];
+    var op = d[0];
+    var values = d[1];
+    var val;
+    for (val in values) {
+      mtrack_add_field(prop, op, values[val]);
+    }
+  }
+
+  $('#addfilt').change(function () {
+    if (!adding_field) {
+      adding_field = true;
+      mtrack_add_field(this.options[this.selectedIndex].value, null, null);
+      this.selectedIndex = 0;
+      adding_field = false;
+    }
+  });
+
+  $('#updfilt').click(function (){
+    var filt = [];
+    // Iterate the form elements and build up the query string
+    var i;
+    var f = document.getElementById('qform');
+    for (i = 0; i < f.length; i++) {
+      var ele = f.elements[i];
+      if (ele.name.match(/^op(sel|val|typ)_/)) {
+        var fid = ele.name.substr(6);
+        var oper = document.getElementById('opsel_' + fid);
+        var val = document.getElementById('opval_' + fid);
+        var type = document.getElementById('optyp_' + fid);
+        filt[fid] = [type.value, oper.value, val.value];
+      }
+    }
+    var qs = "";
+    for (i in filt) {
+      f = filt[i];
+      if (qs.length) {
+        qs += "&";
+      }
+      qs += f[0] + f[1] + f[2];
+    }
+    // And the columns
+    var col = [];
+    $('input.qrycol:checked').each(function () {
+      col[col.length] = $(this).attr('mtrackcol');
+    });
+    qs = qs + "&col=" + col.join('|');
+    document.location.href = "{$ABSWEB}query.php?" + qs;
+    return false;
+  });
+});
+</script>
+HTML;
+
+if (strlen(trim($qs))) {
+  echo MTrackReport::macro_TicketQuery($qs);
+}
+
+mtrack_foot();
+
diff --git a/web/report.php b/web/report.php
new file mode 100644 (file)
index 0000000..95baee0
--- /dev/null
@@ -0,0 +1,126 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+$pi = mtrack_get_pathinfo();
+$edit = isset($_REQUEST['edit']);
+
+if (!strlen($pi)) {
+  if ($edit) {
+    MTrackACL::requireAllRights('Reports', 'create');
+    $rep = new MTrackReport;
+  } else {
+    throw new Exception("no report to render");
+  }
+} elseif (ctype_digit($pi)) {
+  $rep = MTrackReport::loadByID($pi);
+  MTrackACL::requireAllRights("report:" . $rep->rid, $edit ? 'modify' : 'read');
+} else {
+  $rep = MTrackReport::loadBySummary($pi);
+  MTrackACL::requireAllRights("report:" . $rep->rid, $edit ? 'modify' : 'read');
+}
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  $rep->summary = $_POST['name'];
+  $rep->description = $_POST['description'];
+  $rep->query = $_POST['query'];
+
+  if (isset($_POST['cancel'])) {
+    header("Location: {$ABSWEB}reports.php");
+    exit;
+  }
+
+  if (isset($_POST['save'])) {
+    try {
+      $cs = MTrackChangeset::begin(
+              "report:" . $rep->summary, $_POST['comment']);
+      $rep->save($cs);
+      $cs->commit();
+      header("Location: {$ABSWEB}report.php/$rep->rid");
+      exit;
+    } catch (Exception $e) {
+      $message = $e->getMessage();
+    }
+  }
+}
+
+if (isset($_GET['format'])) {
+  // targeted report format; omit decoration
+  $params = $_GET;
+  unset($params['format']);
+  switch ($_GET['format']) {
+    case 'tab':
+      header('Content-Type: text/plain');
+      break;
+  }
+  echo $rep->renderReport($rep->query, $params, $_GET['format']);
+  exit;
+}
+
+if ($rep->rid) {
+  if ($edit) {
+    mtrack_head('{' . $rep->rid . '} ' . $rep->summary . " (edit)");
+  } else {
+    mtrack_head('{' . $rep->rid . '} ' . $rep->summary);
+  }
+} else {
+  mtrack_head("Create Report");
+}
+
+if (!empty($message)) {
+  echo "<div class='error'>" . htmlentities($message, ENT_COMPAT, 'utf-8') . "</div>\n";
+}
+
+if (!$edit || isset($_POST['preview'])) {
+  echo "<h1>" . htmlentities($rep->summary, ENT_COMPAT, 'utf-8') . "</h1>";
+  echo MTrackWiki::format_to_html($rep->description);
+  echo $rep->renderReport($rep->query);
+
+  if ($edit) {
+    echo "<hr>";
+  } else if (MTrackACL::hasAllRights("report:" . $rep->rid, 'modify')) {
+    echo <<<HTML
+<form name="editreport" method="GET" action="{$ABSWEB}report.php/$rep->rid">
+<button type="submit" name="edit">Edit Report</button>
+</form>
+HTML;
+  }
+}
+
+
+if ($edit) {
+  echo <<<HTML
+<form name="editreport" method="POST" action="{$ABSWEB}report.php/$rep->rid">
+<input type="hidden" name="edit" value="1">
+HTML;
+
+  if ($rep->rid) {
+    echo "<input type='hidden' name='rid' value='$rep->rid'/>\n";
+    echo '{' . $rep->rid . '} ';
+  }
+
+  $name = htmlentities($rep->summary, ENT_QUOTES, 'utf-8');
+  $desc = htmlentities($rep->description, ENT_QUOTES, 'utf-8');
+  $query = htmlentities($rep->query, ENT_QUOTES, 'utf-8');
+
+  echo <<<HTML
+<label>Name: <input type="text" size="60" name='name' value="$name"></label><br/>
+<label>Description:<br/>
+<textarea name="description" rows="12" cols="76">$desc</textarea>
+</label><br/>
+<label>SQL Query:<br/>
+<textarea name="query" class="code" rows="20" cols="76">$query</textarea>
+</label>
+<div class="buttons">
+  <button type="submit" name="preview">Preview</button>
+  <button type="submit" name="cancel">Cancel</button>
+</div>
+  Reason for change: <input type="text" name="comment">
+  <button type="submit" name="save">Save changes</button>
+
+</form>
+HTML;
+
+}
+mtrack_foot();
diff --git a/web/reports.php b/web/reports.php
new file mode 100644 (file)
index 0000000..8aa5d27
--- /dev/null
@@ -0,0 +1,45 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+
+mtrack_head("Reports");
+?>
+<h1>Available Reports</h1>
+
+<p>
+  The reports below are constructed using SQL.  You may also
+  use the <a href="<?php echo $ABSWEB ?>query.php">Custom Query</a>
+  page to create a report on the fly.
+</p>
+
+<table>
+<tr>
+  <th>Report</th>
+  <th>Title</th>
+</tr>
+<?php
+foreach (MTrackDB::q("select rid, summary from reports order by rid"
+    )->fetchAll(PDO::FETCH_ASSOC) as $row)
+{
+  $url = "${ABSWEB}report.php/$row[rid]";
+  $t = "<a href='$url'>{" . $row['rid'] . "}</a>";
+  $s = htmlentities($row['summary'], ENT_COMPAT, 'utf-8');
+  $s = "<a href='$url'>$s</a>";
+
+  echo <<<HTML
+<tr><td>$t</td><td>$s</td></tr>
+HTML;
+}
+?>
+</table>
+<?php
+if (MTrackACL::hasAllRights('Reports', 'create')) {
+?>
+<form action="report.php" method="get">
+<button type="submit" name="edit">Create Report</button>
+</form>
+<?php
+}
+
+mtrack_foot();
+
diff --git a/web/roadmap.php b/web/roadmap.php
new file mode 100644 (file)
index 0000000..7c75fe4
--- /dev/null
@@ -0,0 +1,94 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+MTrackACL::requireAllRights('Roadmap', 'read');
+
+mtrack_head("Roadmap");
+
+$completed = isset($_GET['completed']) ? 'checked' : '';
+$watched = isset($_GET['watched']) ?
+  'checked' : (count($_GET) ? '' : 'checked');
+
+echo <<<HTML
+<h1>Roadmap</h1>
+<div style="float:right">
+<form method="get">
+  <input type="checkbox" id='completed' name="completed" $completed>
+  <label for='completed'>Show completed milestones</label><br/>
+  <input type="checkbox" id='watched' name="watched" $watched>
+  <label for='watched'>Show only watched milestones</label><br/>
+  <button type="submit" name='s'>Update</button><br>
+</form>
+<button onclick="document.location.href='{$ABSWEB}milestone.php?new=1';return false;">Add Milestone</button>
+<script type='text/javascript'>
+var showingGraphs = true;
+function toggleGraphs()
+{
+  if (showingGraphs) {
+    $('.burndown').hide();
+  } else {
+    $('.burndown').show();
+  }
+  showingGraphs = !showingGraphs;
+}
+</script>
+<button onclick="toggleGraphs(); return false;">Toggle Graphs</button>
+</div>
+HTML;
+$db = MTrackDB::get();
+
+if (!empty($_GET['completed'])) {
+  $comp = "";
+} else {
+  $comp = " AND completed IS NULL ";
+}
+
+if ($watched == 'checked') {
+  $me = $db->quote(mtrack_canon_username(MTrackAuth::whoami()));
+  if ($db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'pgsql') {
+    $oid = 'w.oid::integer';
+  } else {
+    $oid = 'w.oid';
+  }
+  $sql = <<<SQL
+SELECT distinct name, duedate
+FROM watches w
+LEFT JOIN milestones m on (m.mid = $oid)
+  WHERE
+    w.userid = $me AND
+    w.otype = 'milestone' AND
+    deleted != 1
+    AND pmid IS NULL
+    $comp
+ORDER by duedate ASC, name
+SQL;
+
+} else {
+  $sql = <<<SQL
+SELECT name
+FROM milestones
+  WHERE 
+    deleted != 1
+    AND pmid IS NULL
+  $comp 
+ORDER by case when duedate IS NULL then 1 else 0 end, duedate ASC, name
+SQL;
+}
+
+$i = 0;
+foreach ($db->query($sql)->fetchAll(PDO::FETCH_ASSOC) as $row) {
+  echo MTrackMilestone::macro_MilestoneSummary($row['name']);
+  $i++;
+}
+
+if ($i == 0) {
+  $milestones = $watched == 'checked' ? 'watched milestones' : 'milestones';
+  echo <<<HTML
+<p><em>No $milestones were found.</em></p>
+HTML;
+}
+
+mtrack_foot();
+
diff --git a/web/search.php b/web/search.php
new file mode 100644 (file)
index 0000000..1b33373
--- /dev/null
@@ -0,0 +1,250 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+
+$q = $_GET['q'];
+
+if (preg_match('/^#([a-zA-Z0-9]+)$/', $q, $M)) {
+  /* ticket */
+  header("Location: {$ABSWEB}ticket.php/$M[1]");
+  exit;
+}
+if (preg_match('/^r([a-zA-Z]*\d+)$/', $q, $M)) {
+  /* changeset */
+  $url = mtrack_changeset_url($M[1]);
+  header("Location: $url");
+  exit;
+}
+if (preg_match('/^\[([a-zA-Z]*\d+)\]$/', $q, $M)) {
+  /* changeset */
+  $url = mtrack_changeset_url($M[1]);
+  header("Location: $url");
+  exit;
+}
+if (preg_match('/^\{(\d+)\}$/', $q, $M)) {
+  /* report */
+  header("Location: {$ABSWEB}report.php/$M[1]");
+  exit;
+}
+mtrack_head("Search results for \"$q\"");
+
+?>
+<h1>Search results</h1>
+
+<form action="<?php echo $ABSWEB; ?>search.php">
+  <input type="text" name="q"
+    size="50"
+    value="<?php echo htmlentities($q, ENT_QUOTES, 'utf-8'); ?>">
+  <button type="submit">Search</button>
+  Read more about <a href="<?php echo $ABSWEB ; ?>help.php/Searching">Searching</a>.
+  <button id='togglesummary' type='button'>Show Fields</button>
+<script>
+$(document).ready(function () {
+  $('#togglesummary').click(function () {
+    $('#fieldsummary').toggle();
+  });
+});
+</script>
+<div id='fieldsummary' style='display:none'>
+  <p>The following fields are available for targeted searching:</p>
+  <table>
+    <tr>
+      <th>Item</th>
+      <th>Field</th>
+      <th>Description</th>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>summary</td>
+      <td>The one-line ticket summary</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>description</td>
+      <td>The ticket description</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>changelog</td>
+      <td>The changelog field</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>keyword</td>
+      <td>The keyword field</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>date</td>
+      <td>The last-changed date</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>who</td>
+      <td>who last changed the ticket</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>creator</td>
+      <td>who opened the ticket</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>created</td>
+      <td>The date that the ticket was created</td>
+    </tr>
+    <tr>
+      <td>Ticket</td>
+      <td>owner</td>
+      <td>who is responsible for the ticket</td>
+    </tr>
+    <tr>
+      <td>Comment</td>
+      <td>description</td>
+      <td>The comment text</td>
+    </tr>
+    <tr>
+      <td>Comment</td>
+      <td>date</td>
+      <td>Date the comment was made</td>
+    </tr>
+    <tr>
+      <td>Comment</td>
+      <td>who</td>
+      <td>who made that comment</td>
+    </tr>
+    <tr>
+      <td>Wiki</td>
+      <td>wiki</td>
+      <td>The content from the wiki page</td>
+    </tr>
+    <tr>
+      <td>Wiki</td>
+      <td>who</td>
+      <td>Who last changed that wiki page</td>
+    </tr>
+    <tr>
+      <td>Wiki</td>
+      <td>date</td>
+      <td>Date the wiki page was last changed</td>
+    </tr>
+<?php
+$CF = MTrackTicket_CustomFields::getInstance();
+foreach ($CF->fields as $f) {
+  echo "<tr><td>Ticket</td><td>$f->name</td><td>",
+    htmlentities($f->label, ENT_QUOTES, 'utf-8'),
+    "</td></tr>\n";
+}
+?>
+  </table>
+
+</div>
+</form>
+
+<?php
+
+if (strlen($q)) {
+  $start = microtime(true);
+  $hits = MTrackSearchDB::search($q);
+  $end = microtime(true);
+  $elapsed = sprintf("%.2f seconds", $end - $start);
+} else {
+  $hits = array();
+  $elapsed = '';
+}
+?>
+
+
+<p>Searching for <i>
+
+<?php
+echo htmlentities($q, ENT_QUOTES, 'utf-8'), "</i>:</p><br>\n";
+
+$hits_by_object = array();
+$objects = array();
+/* aggregate results by canonical object; since
+ * we index comments separately from the top level
+ * item, we need to adjust for that here */
+foreach ($hits as $hit) {
+  /* get canonical object */
+  list($item, $id) = explode(':', $hit->objectid, 3);
+
+  $object = "$item:$id";
+  if (isset($hits_by_object[$object])) {
+    if ($hit->score > $hits_by_object[$object]) {
+      $hits_by_object[$object] = $hit->score;
+      $objects[$object] = $hit;
+    }
+  } else {
+    $hits_by_object[$object] = $hit->score;
+    $objects[$object] = $hit;
+  }
+}
+arsort($hits_by_object);
+?>
+<table class='searchresults'>
+<?php
+
+
+
+$denied = 0;
+foreach ($hits_by_object as $object => $score) {
+  list($item, $id) = explode(':', $object, 2);
+  $obj = $objects[$object];
+  $score = (int)($score * 100);
+
+  $html = "<tr><td valign='right'>$score%</td><td>";
+
+  switch ($item) {
+    case 'ticket':
+      $tkt = MTrackIssue::loadByNSIdent($id);
+      if ($tkt === null) {
+        $tkt = MTrackIssue::loadById($id);
+      }
+      $aclid = "ticket:" . $tkt->tid;
+      $html .= mtrack_ticket($tkt);
+      if ($tkt->nsident) {
+        $url = "{$ABSWEB}ticket.php/$tkt->nsident";
+      } else {
+        $url = "{$ABSWEB}ticket.php/$id";
+      }
+      $html .= " <a href='$url'>";
+      $html .= htmlentities($tkt->summary, ENT_QUOTES, 'utf-8');
+      $html .= "</a>";
+      $html .= $obj->getExcerpt($tkt->description);
+
+      break;
+    case 'wiki':
+      $wiki = MTrackWikiItem::loadByPageName($id);
+      $aclid = "wiki:$id";
+      $url = "{$ABSWEB}wiki.php/$id";
+      $html .= "<a href='$url'>".
+        htmlentities($id, ENT_QUOTES, 'utf-8').
+        "</a>";
+      $html .= $obj->getExcerpt($wiki->content);
+      break;
+    default:
+      $aclid = $object;
+      $html .= $object;
+  }
+
+  if (!MTrackACL::hasAnyRights($aclid, 'read')) {
+    $denied++;
+    continue;
+  }
+
+  $html .= "</td></tr>\n";
+  echo $html;
+}
+echo "</table>\n";
+
+if (!count($hits_by_object)) {
+  echo "<em>No matches</em>";
+} else {
+  echo "<em>" . count($hits_by_object) . " results in $elapsed</em>\n";
+}
+if ($denied) {
+  echo "<br>Denied access to $denied items<br>\n";
+}
+
+mtrack_foot();
diff --git a/web/snippet.php b/web/snippet.php
new file mode 100644 (file)
index 0000000..f9600a6
--- /dev/null
@@ -0,0 +1,141 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+MTrackACL::requireAllRights('Snippets', 'read');
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['submit'])) {
+  MTrackACL::requireAllRights('Snippets', 'create');
+
+  $snip = new MTrackSnippet;
+  $snip->snippet = $_POST['code'];
+  $snip->description = $_POST['description'];
+  $snip->lang = $_POST['lang'];
+  $cs = MTrackChangeset::begin("snippet:?", $snip->description);
+  $snip->save($cs);
+  $cs->setObject("snippet:$snip->snid");
+  $cs->commit();
+  header("Location: {$ABSWEB}snippet.php/$snip->snid");
+  exit;
+}
+
+$pi = mtrack_get_pathinfo();
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  $snip = new MTrackSnippet;
+  $snip->description = $_POST['description'];
+  $snip->lang = $_POST['lang'];
+  $snip->snippet = $_POST['code'];
+} elseif (strlen($pi)) {
+  $snip = MTrackSnippet::loadById($pi);
+  if (!$snip) {
+    throw new Exception("Invalid snippet ID");
+  }
+} else {
+  $snip = null;
+}
+
+if ($snip) {
+  $lang = $snip->lang;
+  $code = $snip->snippet;
+  $desc = $snip->description;
+  mtrack_head("Snippet $pi");
+} else {
+  $lang = '';
+  $code = 'Enter your snippet here';
+  $desc = 'Enter a descriptive message here; you may use wiki syntax';
+  mtrack_head("New Snippet");
+}
+
+echo "<table><tr>";
+
+
+/* collect recent snippets */
+$recent = MTrackDB::q('select snid, description, who, changedate
+from snippets
+  left join changes on snippets.updated = changes.cid
+  order by changes.changedate desc
+  limit 10')->fetchAll(PDO::FETCH_OBJ);
+
+echo <<<HTML
+<td id='recentsnippets'>
+<em>Snippets are a way to share text or code fragments</em><br><br>
+HTML;
+
+if (MTrackACL::hasAllRights('Snippets', 'create')) {
+  echo <<<HTML
+  <button id='newsnippet'>New Snippet</button><br>
+<script>
+\$(document).ready(function () {
+  \$('#newsnippet').click(function () {
+    document.location.href = "{$ABSWEB}snippet.php";
+  });
+});
+</script>
+HTML;
+}
+
+echo <<<HTML
+  <b>Recent Snippets</b>
+HTML;
+
+foreach ($recent as $s) {
+  $url = "{$ABSWEB}snippet.php/$s->snid";
+  $sum = MTrackWiki::format_to_oneliner($s->description);
+  $who = mtrack_username($s->who, array('no_image' => true));
+  $when = mtrack_date($s->changedate);
+  echo <<<HTML
+  <div class='snippetsummary'>
+    $sum<br>
+    $when by $who<br>
+    <a href='$url'>view snippet</a>
+  </div>
+HTML;
+}
+echo "</td><td>";
+
+if (MTrackACL::hasAllRights('Snippets', 'create') &&
+    (!$snip || $_SERVER['REQUEST_METHOD'] == 'POST')) {
+  echo "<form method='post' class='snippetform' action='{$ABSWEB}snippet.php'>";
+  echo "<textarea name='description' class='wiki shortwiki'>$desc</textarea>\n";
+  echo MTrackSyntaxHighlight::getLangSelect('lang', $lang);
+  echo "<br><textarea name='code' class='code' rows='20' cols='78'>";
+  echo htmlentities($code, ENT_QUOTES, 'utf-8');
+  echo "</textarea><br>";
+  echo "<button type='submit' name='preview'>Preview</button>\n";
+  echo "<button type='submit' name='submit'>Submit</button>\n";
+  echo "</form>";
+}
+
+if ($snip) {
+  echo "<div class='snippetview'>";
+  echo "<h1>Snippet</h1>";
+  if ($snip->created) {
+    $created = MTrackChangeset::get($snip->created);
+  } else {
+    $created = new stdclass;
+  }
+
+  echo "<span id='snippetmug'>",
+    mtrack_username($created->who, array('no_name' => true, 'size' => 48)),
+    "</span>";
+  echo "<b>Created</b>: ",
+       mtrack_date($created->when),
+       " by ",
+       mtrack_username($created->who, array('no_image' => true)),
+       "<br>\n";
+  echo "<a href='{$ABSWEB}snippet.php/$snip->snid'>Link to this snippet</a><br>";
+
+  echo MTrackWiki::format_to_html($snip->description);
+  echo "<br><br>";
+  echo MTrackSyntaxHighlight::getSchemeSelect();
+  echo MTrackSyntaxHighlight::highlightSource($code, $lang, null, true);
+  echo "</div>";
+} else if (!MTrackACL::hasAllRights('Snippets', 'create')) {
+  echo "<p>You do not have rights to create snippets</p>";
+}
+
+echo "</td></tr></table>";
+
+mtrack_foot();
diff --git a/web/ticket.php b/web/ticket.php
new file mode 100644 (file)
index 0000000..7ee22cc
--- /dev/null
@@ -0,0 +1,1377 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+include '../inc/common.php';
+
+if ($pi = mtrack_get_pathinfo()) {
+  $id = $pi;
+} else {
+  $id = $_GET['id'];
+}
+
+if ($id == 'new') {
+  $issue = new MTrackIssue;
+  $issue->priority = 'normal';
+} else {
+  if (strlen($id) == 32) {
+    $issue = MTrackIssue::loadById($id);
+  } else {
+    $issue = MTrackIssue::loadByNSIdent($id);
+  }
+  if (!$issue) {
+    throw new Exception("Invalid ticket $id");
+  }
+}
+
+$FIELDSET = array(
+    array(
+      "description" => array(
+        "label" => "Full description",
+        "ownrow" => true,
+        "type" => "wiki",
+        "rows" => 10,
+        "cols" => 78,
+        "editonly" => true,
+        ),
+      ),
+    "Properties" => array(
+      "milestone" => array(
+        "label" => "Milestone",
+        "type" => "multiselect",
+        ),
+      "component" => array(
+        "label" => "Component",
+        "type" => "multiselect",
+        ),
+      "classification" => array(
+        "label" => "Classification",
+        "type" => "select",
+        ),
+      "priority" => array(
+        "label" => "Priority",
+        "type" => "select",
+        ),
+      "severity" => array(
+        "label" => "Severity",
+        "type" => "select",
+        ),
+      "keywords" => array(
+          "label" => "Keywords",
+          "type" => "text",
+          ),
+      "changelog" => array(
+          "label" => "ChangeLog (customer visible)",
+          "type" => "multi",
+          "ownrow" => true,
+          "rows" => 5,
+          "cols" => 78,
+       #   "condition" => $issue->status == 'closed'
+          ),
+      ),
+      "Resources" => array(
+          "owner" => array(
+            "label" => "Responsible",
+            "type" => "select"
+            ),
+          "estimated" => array(
+            "label" => "Estimated Hours",
+            "type" => "text"
+            ),
+          "spent" => array(
+            "label" => "Spent Hours",
+            "type" => "text",
+            "readonly" => true,
+            ),
+          "cc" => array(
+            "label" => "Cc",
+            "type" => "text"
+            ),
+          ),
+      );
+$issue->augmentFormFields($FIELDSET);
+
+
+$preview = false;
+$error = array();
+
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+  if (isset($_POST['cancel'])) {
+    header("Location: {$ABSWEB}ticket.php/$issue->nsident");
+    exit;
+  }
+  if (!MTrackCaptcha::check('ticket')) {
+    $error[] = "CAPTCHA failed, please try again";
+  }
+  $preview = isset($_POST['preview']) ? true : false;
+
+  $comment = '';
+  try {
+    if ($id == 'new') {
+      MTrackACL::requireAllRights("Tickets", 'create');
+    } else {
+      MTrackACL::requireAllRights("ticket:" . $issue->tid, 'modify');
+    }
+  } catch (Exception $e) {
+    $error[] = $e->getMessage();
+  }
+  if ($id == 'new') {
+    $comment = $_POST['comment'];
+  }
+  if (!strlen($comment)) {
+    $comment = $_POST['summary'];
+  }
+  try {
+    $CS = MTrackChangeset::begin("ticket:X", $comment);
+  } catch (Exception $e) {
+    $error[] = $e->getMessage();
+    $CS = null;
+  }
+  if ($id == 'new') {
+    // compute next id number.
+    // We don't use auto-number, because we allow for importing multiple
+    // projects with their own ticket sequence.
+    // During "normal" user-driven operation, we do want plain old id numbers
+    // so we compute it here, under a transaction
+    $db = MTrackDB::get();
+    if ($db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'pgsql') {
+        // Some versions of postgres don't like that we have "abc123" for
+        // identifiers, so match on the bigest number nsident fields only
+      $max = "select max(cast(nsident as integer)) + 1 from tickets where nsident ~ '^\\\\d+$'";
+    } else {
+      $max = 'select max(cast(nsident as integer)) + 1 from tickets';
+    }
+    list($issue->nsident) = MTrackDB::q($max)->fetchAll(PDO::FETCH_COLUMN, 0);
+    if ($issue->nsident === null) {
+      $issue->nsident = 1;
+    }
+  }
+
+  if (isset($_POST['action']) && !$preview) {
+    switch ($_POST['action']) {
+      case 'leave':
+        break;
+      case 'reopen':
+        $issue->reOpen();
+        break;
+      case 'fixed':
+        $issue->resolution = 'fixed';
+        $issue->close();
+        $_POST['estimated'] = $issue->estimated;
+        break;
+      case 'resolve':
+        $issue->resolution = $_POST['resolution'];
+        $issue->close();
+        $_POST['estimated'] = $issue->estimated;
+        break;
+      case 'accept':
+        // will be applied to the issue further down
+        $_POST['owner'] = MTrackAuth::whoami();
+        if ($issue->status == 'new') {
+          $issue->status = 'open';
+        }
+        break;
+      case 'changestatus':
+        $issue->status = $_POST['status'];
+        break;
+    }
+  }
+
+  $fields = array(
+    'summary',
+    'description',
+    'classification',
+    'priority',
+    'severity',
+    'changelog',
+    'owner',
+    'cc',
+  );
+
+  $issue->applyPOSTData($_POST);
+
+  foreach ($fields as $fieldname) {
+    if (isset($_POST[$fieldname]) && strlen($_POST[$fieldname])) {
+      $issue->$fieldname = $_POST[$fieldname];
+    } else {
+      $issue->$fieldname = null;
+    }
+  }
+
+  $kw = $issue->getKeywords();
+  $kill = array_values($kw);
+  foreach (preg_split('/[ \t,]+/', $_POST['keywords']) as $w) {
+    if (!strlen($w)) {
+      continue;
+    }
+    $x = array_search($w, $kw);
+    if ($x === false) {
+      $k = MTrackKeyword::loadByWord($w);
+      if ($k === null) {
+        $k = new MTrackKeyword;
+        $k->keyword = $w;
+        $k->save($CS);
+      }
+      $issue->assocKeyword($k);
+    } else {
+      $w = array_search($w, $kill);
+      if ($w !== false) {
+        unset($kill[$w]);
+      }
+    }
+  }
+  foreach ($kill as $w) {
+    $issue->dissocKeyword($w);
+  }
+
+  $ms = $issue->getMilestones();
+  $kill = $ms;
+  if (isset($_POST['milestone']) && is_array($_POST['milestone'])) {
+    foreach ($_POST['milestone'] as $mid) {
+      $issue->assocMilestone($mid);
+      unset($kill[$mid]);
+    }
+  }
+  foreach ($kill as $mid) {
+    $issue->dissocMilestone($mid);
+  }
+
+  $ms = $issue->getComponents();
+  $kill = $ms;
+  if (isset($_POST['component']) && is_array($_POST['component'])) {
+    foreach ($_POST['component'] as $mid) {
+      $issue->assocComponent($mid);
+      unset($kill[$mid]);
+    }
+  }
+  foreach ($kill as $mid) {
+    $issue->dissocComponent($mid);
+  }
+
+  $issue->addComment($_POST['comment']);
+  $issue->addEffort($_POST['spent'], $_POST['estimated']);
+
+  if (!count($error)) {
+    try {
+      $issue->save($CS);
+      $CS->setObject("ticket:" . $issue->tid);
+    } catch (Exception $e) {
+      $error[] = $e->getMessage();
+    }
+  }
+
+  if (!count($error)) {
+    if (isset($_FILES['attachments']) && is_array($_FILES['attachments'])) {
+      foreach ($_FILES['attachments']['name'] as $fileid => $name) {
+        MTrackAttachment::add("ticket:$issue->tid",
+            $_FILES['attachments']['tmp_name'][$fileid],
+            $_FILES['attachments']['name'][$fileid],
+            $CS);
+      }
+    }
+  }
+  if (!count($error) && $id != 'new') {
+    MTrackAttachment::process_delete("ticket:$issue->tid", $CS);
+  }
+
+  if (isset($_POST['apply']) && !count($error)) {
+    $CS->commit();
+    header("Location: {$ABSWEB}ticket.php/$issue->nsident");
+    exit;
+  }
+}
+
+if ($id == 'new') {
+  MTrackACL::requireAllRights("Tickets", 'create');
+  mtrack_head("New ticket");
+} else {
+  MTrackACL::requireAllRights("ticket:" . $issue->tid, 'read');
+  if ($issue->nsident) {
+    mtrack_head("#$issue->nsident " . $issue->summary);
+  } else {
+    mtrack_head("#$id " . $issue->summary);
+  }
+}
+
+echo "<form id='tktedit' method='post' action='{$ABSWEB}ticket.php/$id' enctype='multipart/form-data'>\n";
+/* now to render the edit controls, if suitably privileged */
+if ($id == 'new') {
+  $editable = MTrackACL::hasAllRights("Tickets", 'create');
+} else {
+  $editable = MTrackACL::hasAllRights("ticket:" . $issue->tid, 'modify');
+}
+
+echo <<<HTML
+<div id="issue-desc">
+HTML;
+
+if ($preview) {
+  echo <<<HTML
+<div class='ui-state-highlight ui-corner-all'>
+    <span class='ui-icon ui-icon-info'></span>
+    This is a preview of your pending changes.  It does not show
+    changes to the resolution; those will be applied when you submit.
+</div>
+
+HTML;
+}
+if (count($error)) {
+  foreach ($error as $e) {
+    echo <<<HTML
+<div class='ui-state-error ui-corner-all'>
+    <span class='ui-icon ui-icon-alert'></span>
+HTML;
+    echo htmlentities($e, ENT_QUOTES, 'utf-8') . "\n</div>\n";
+  }
+}
+
+if ($id != 'new') {
+  echo "<h1>";
+  if (!$issue->isOpen()) {
+    echo "<del>";
+  }
+  if ($issue->nsident) {
+    echo "#$issue->nsident ";
+  } else {
+    echo "#$id ";
+  }
+
+  echo htmlentities($issue->summary, ENT_QUOTES, 'utf-8');
+
+  if (!$issue->isOpen()) {
+    echo "</del>";
+  }
+  echo "</h1>\n";
+}
+
+if ($id == 'new') {
+  $created = new stdClass;
+  $created->when = MTrackDB::unixtime(time());
+  $created->who = MTrackAuth::whoami();
+} else {
+  $created = MTrackChangeset::get($issue->created);
+}
+
+$opened = mtrack_date($created->when);
+echo <<<HTML
+<div id="ticketinfo">
+HTML;
+
+$pseudo_fields = array();
+
+if ($id != 'new') {
+  echo "<table id='ctime'><tr><td><label>Opened</label>:</td><td>",
+       mtrack_date($created->when),
+       "</td><td>",
+       mtrack_username($created->who, array('no_image' => true)),
+       "</td></tr>\n";
+  if ($issue->updated != $issue->created) {
+    $updated = MTrackChangeset::get($issue->updated);
+    echo "<tr><td><label>Updated</label>:</td><td>",
+      mtrack_date($updated->when),
+      "</td><td>",
+      mtrack_username($updated->who, array('no_image' => true)),
+      "</td></tr>";
+  }
+  echo "</table>";
+
+  $v = get_components_list(join(',', array_keys($issue->getComponents())));
+  $pseudo_fields['@components'] = $v;
+
+  $v = get_milestones_list(join(',', array_keys($issue->getMilestones())));
+  $pseudo_fields['@milestones'] = $v;
+
+  $v = get_keywords_list(join(',', array_keys($issue->getKeywords())));
+  $pseudo_fields['@keywords'] = $v;
+
+  $ROFIELDSET = $FIELDSET;
+  $ROFIELDSET['Properties']['resolution'] = array(
+    'label' => 'Resolution',
+    'type' => 'text',
+  );
+
+  foreach ($ROFIELDSET as $fsid => $fieldset) {
+    $emited = false;
+    foreach ($fieldset as $propname => $info) {
+      if (isset($info['editonly'])) {
+        continue;
+      }
+      $value = null;
+      switch ($propname) {
+        case 'keywords':
+          $value = array();
+          foreach ($issue->getKeywords() as $kw) {
+            $value[] = mtrack_keyword($kw);
+          }
+          $value = join(' ', $value);
+          break;
+        case 'milestone':
+          $value = $pseudo_fields['@milestones'];
+          break;
+        case 'component':
+          $value = $pseudo_fields['@components'];
+          break;
+        default:
+          $value = $issue->$propname;
+      }
+
+      if (strlen($value)) {
+        if (!$emited) {
+          $rfsid = 'readonly-tkt-' .
+            preg_replace('/[^a-z]+/', '', strtolower($fsid));
+          echo "<fieldset id='$rfsid'><legend>$fsid</legend>\n<table>";
+          $emited = true;
+        }
+
+        switch ($info['type']) {
+          case 'wiki':
+            $value = MTrackWiki::format_to_html($value);
+            break;
+          case 'multi':
+            $value = nl2br(htmlentities($value, ENT_QUOTES, 'utf-8'));
+            break;
+        }
+
+        if (isset($info['ownrow']) && $info['ownrow'] == 'true') {
+          echo "<tr><td colspan='2'><label>$info[label]</label>:</td></tr>";
+          echo "<td colspan='2'>$value</td></tr>\n";
+        } else {
+          echo "<tr><td><label>$info[label]</label>:</td><td width='100%'>$value</td></tr>\n";
+        }
+      }
+    }
+    if ($emited) {
+      echo "</table></fieldset>\n";
+    }
+  }
+}
+echo "</div>\n";
+
+if ($issue->tid !== null) {
+  echo MTrackAttachment::renderList("ticket:$issue->tid");
+}
+
+if ($id != 'new') {
+  echo "<div id='readonly-tkt-description'>";
+  echo MTrackWiki::format_to_html($issue->description);
+  echo "</div>";
+}
+
+if ($editable && $id != 'new' && !$preview) {
+  echo "<br><div id='tkt-view-button-block' class='button-float'>";
+  echo "<button class='mtrack-edit-desc'>Edit</button>";
+  echo " <button class='mtrack-make-comment'>Add Comment</button>";
+  MTrackWatch::renderWatchUI('ticket', $issue->tid);
+  echo "</div>";
+}
+echo "</div>"; # issue-desc
+
+$hide_unless_preview = ($preview || $_SERVER['REQUEST_METHOD'] == 'POST') ?
+  '' :
+  ' style="display:none" ';
+
+if ($editable && $id != 'new') {
+  echo <<<HTML
+<div id="edit-issue-desc" $hide_unless_preview >
+HTML;
+}
+
+if ($editable) {
+
+  echo " <input class='summaryedit' id='summary' name='summary' value='" .
+    htmlentities($issue->summary, ENT_QUOTES, 'utf-8') .
+    "' size='80'>";
+
+  echo renderEditForm($issue);
+}
+
+if ($editable && $id != 'new') {
+  echo "</div>";
+}
+
+
+function get_components_list($value)
+{
+  $res = array();
+  if (strlen($value)) foreach (MTrackDB::q(
+      "select name, deleted from components where compid in ($value)")
+      ->fetchAll() as $row) {
+    $c = $row['deleted'] ? '<del>' : '';
+    $c .= htmlentities($row['name'], ENT_QUOTES, 'utf-8');
+    $c .= $row['deleted'] ? '</del>' : '';
+    $res[] = $c;
+  }
+  return join(", ", $res);
+}
+
+function get_milestones_list($value)
+{
+  global $ABSWEB;
+
+  $res = array();
+  if (strlen($value)) foreach (MTrackDB::q(
+      "select name, completed, deleted from milestones where mid in ($value)")
+      ->fetchAll() as $row) {
+    if (strlen($row['completed'])) {
+      $row['deleted'] = 1;
+    }
+    $c = "<span class='milestone";
+    if ($row['deleted']) {
+      $c .= " completed";
+    }
+    $c .= "'><a href=\"{$ABSWEB}milestone.php/" .
+          urlencode($row['name']) . '">';
+    $c .= htmlentities($row['name'], ENT_QUOTES, 'utf-8');
+    $c .= "</a></span>";
+    $res[] = $c;
+  }
+  return join(", ", $res);
+}
+
+function get_keywords_list($value)
+{
+  $res = array();
+  if (strlen($value)) foreach (MTrackDB::q(
+      "select keyword from keywords where kid in ($value)")
+      ->fetchAll() as $row) {
+    $res[] = htmlentities($row['keyword'], ENT_QUOTES, 'utf-8');
+  }
+  return join(", ", $res);
+}
+
+if ($id == 'new') {
+  $changes = array();
+} else {
+  $changes = array();
+  $cids = array();
+  foreach (MTrackDB::q(
+        'select * from changes where object = ?
+        order by changedate asc',
+        "ticket:$issue->tid")->fetchAll(PDO::FETCH_OBJ) as $CS) {
+    $changes[$CS->cid] = $CS;
+    $cids[] = $CS->cid;
+  }
+  $cidlist = join(',', $cids);
+
+  $change_audit = array();
+  foreach (MTrackDB::q("select * from change_audit where cid in ($cidlist)")
+      ->fetchAll(PDO::FETCH_ASSOC) as $citem) {
+    $change_audit[$citem['cid']][] = $citem;
+  }
+
+  /* also need to include cases where the ticket was modified as a side-effect
+   * of other manipulations (such as milestones being closed and tickets being
+   * re-targeted.  Such manipulations do not directly reference this ticket,
+   * and so do not need to be included in the effort_audit array that is
+   * populated below. */
+
+  $tid = $issue->tid;
+  foreach (MTrackDB::q(
+    "select c.cid as cid, c.who as who, c.object as object, c.changedate as changedate, c.reason as reason, ca.fieldname as fieldname, ca.action as action, ca.oldvalue as oldvalue, ca.value as value from change_audit ca left join changes c on (ca.cid = c.cid) where ca.cid not in ($cidlist) and ca.fieldname like 'ticket:$tid:%'")
+    ->fetchAll(PDO::FETCH_OBJ) as $CS) {
+    if (!isset($changes[$CS->cid])) {
+      $changes[$CS->cid] = $CS;
+    }
+    $change_audit[$CS->cid][] = array(
+      'cid' => $CS->cid,
+      'fieldname' => $CS->fieldname,
+      'action' => $CS->action,
+      'oldvalue' => $CS->oldvalue,
+      'value' => $CS->value
+      );
+  }
+
+  $effort_audit = array();
+  foreach (MTrackDB::q(
+      "select * from effort where cid in ($cidlist) and tid=?", $issue->tid)
+      ->fetchAll(PDO::FETCH_ASSOC) as $eff) {
+    $effort_audit[$eff['cid']][] = $eff;
+  }
+}
+ksort($changes);
+
+$idno = 0;
+$events = array();
+
+function collapse_diff($diff)
+{
+  static $idnum = 1;
+  $id = 'diff_' . $idnum++;
+  return "<br>" .
+    "<button onclick='\$(&quot;#$id&quot;).toggle(); return false;'>Toggle diff</button>".
+    "<div id='$id' style='display:none'>" .
+    mtrack_diff($diff) . "</div>";
+}
+
+foreach ($changes as $CS) {
+  $preamble = 0;
+  if ($idno == 0) {
+    $cid = "top";
+  } else {
+    $cid = "comment:$idno";
+  }
+  $idno++;
+
+  $who = $CS->who;
+  $timestamp = mtrack_date($CS->changedate, true);
+
+  $html = "<div class='ticketevent'><a class='pmark' href='#$cid'>#</a> <a name='$cid'>$timestamp</a> " .
+    mtrack_username($who, array('no_image' => true)) .
+    "</div>\n";
+
+  $html .= "<div class='ticketchangeinfo'>";
+  $html .= mtrack_username($who, array('no_name' => true, 'size' => 48));
+
+  if ($CS->cid == $issue->created) {
+    $html .= "<b>Opened</b><br>\n";
+  }
+
+  $comments = array();
+
+  if (is_array($change_audit[$CS->cid]))
+  foreach ($change_audit[$CS->cid] as $citem) {
+    list($tbl,,$field) = explode(':', $citem['fieldname'], 3);
+
+    if ($tbl != 'ticket') {
+      // can get here if we created a new keyword, for example
+      //var_dump($citem);
+      continue;
+    }
+    if ($field == '@comment') {
+      $comments[] = $citem['value'];
+      continue;
+    }
+
+    if ($field == '@components') {
+      $citem['value'] = get_components_list($citem['value']);
+    }
+    if ($field == '@milestones') {
+      $citem['value'] = get_milestones_list($citem['value']);
+    }
+    if ($field == '@keywords') {
+      $citem['value'] = get_keywords_list($citem['value']);
+    }
+    if ($field == 'spent') {
+      continue;
+    }
+    if ($field == 'estimated') {
+      if ($citem['value'] !== null) {
+        $citem['value'] += 0;
+      }
+      if ($citem['oldvalue'] !== null) {
+        $citem['oldvalue'] += 0;
+      }
+    }
+
+    if ($field[0] == '@') {
+      $main = isset($pseudo_fields[$field]) ? $pseudo_fields[$field] : '';
+      $field = substr($field, 1, -1);
+    } else {
+      $main = $issue->$field;
+    }
+
+    $f = MTrackTicket_CustomFields::getInstance()->fieldByName($field);
+    if ($f) {
+      $label = htmlentities($f->label, ENT_QUOTES, 'utf-8');
+    } else {
+      if ($field == 'attachment' && strlen($citem['oldvalue'])) {
+        $label = "Attachment: $citem[oldvalue]";
+      } else {
+        $label = ucfirst($field);
+      }
+    }
+
+    if ($citem['oldvalue'] == null) {
+      /* don't bother printing out a set if this is the initial thing
+       * and if the field values are currently the same */
+
+      if ($main != $citem['value'] || $cid != 'top') {
+
+        /* Special case for description; since it is multi-line and often
+         * very large, render it as a diff against the current ticket
+         * description field */
+        if ($field == 'description') {
+          if ($issue->description == $citem['value']) {
+            $html .= "<b>Description</b>: no longer empty; see above<br>";
+            continue;
+          }
+
+          $initial_lines = count(explode("\n", $issue->description));
+          $diff = mtrack_diff_strings($issue->description, $citem['value']);
+          $diff_add = 0;
+          $diff_rem = 0;
+          foreach (explode("\n", $diff) as $line) {
+            if (!strlen($line)) continue;
+            if ($line[0] == '-') {
+              $diff_rem++;
+            } else if ($line[0] == '+') {
+              $diff_add++;
+            }
+          }
+          if (abs($diff_add - $diff_rem) > $initial_lines / 2) {
+            $html .= "<b>initial $label</b><br>" .
+              MTrackWiki::format_to_html($citem['value']);
+          } else {
+            $diff = collapse_diff($diff);
+            $html .= "<b>initial $label</b> (diff to above):<br>$diff\n";
+          }
+        } else {
+          $html .= "<b>$label</b> $citem[value]<br>\n";
+        }
+      }
+    } elseif ($citem['action'] == 'changed') {
+      $lines = explode("\n", $citem['value'], 3);
+      if (count($lines) >= 2) {
+        $diff = mtrack_diff_strings($citem['oldvalue'], $citem['value']);
+        $diff = collapse_diff($diff);
+        $html .= "<b>$label</b> $citem[action]\n$diff\n";
+      } else {
+        $html .= "<b>$label</b> $citem[action] to $citem[value]<br>\n";
+      }
+    } else {
+      $html .= "<b>$label</b> $citem[action]<br>\n";
+    }
+  }
+
+  if (isset($effort_audit[$CS->cid]) && is_array($effort_audit[$CS->cid])) {
+    foreach ($effort_audit[$CS->cid] as $eff) {
+      $exp = (float)$eff['expended'];
+      if ($eff['expended'] != 0) {
+        $html .= "<b>spent</b> $exp hours<br>\n";
+        $preamble++;
+      }
+    }
+  }
+
+  if (count($comments)) {
+    if ($preamble) {
+      $html .= "<br>\n";
+      $preamble = 0;
+    }
+
+    foreach ($comments as $text) {
+      $html .= MTrackWiki::format_to_html($text);
+    }
+  }
+
+  $html .= "</div>"; # ticketchangeinfo
+
+  $events[] = $html;
+}
+if (count($events) > 80 && !isset($_GET['all'])) {
+  $num_hidden = count($events) - 20;
+  $turl = $ABSWEB . 'ticket.php/' . $issue->nsident . '?all=1';
+  echo <<<HTML
+<br>
+<div id='show-overflow' class='ui-state-highlight ui-corner-all'>
+    <span class='ui-icon ui-icon-info'></span>
+    There are $num_hidden older comments that are not shown.
+    <a class='button' href='$turl'>Show hidden comments</button>
+</div>
+HTML;
+} else if (count($events) > 20 && !isset($_GET['all'])) {
+  $num_hidden = count($events) - 20;
+  echo <<<HTML
+<br>
+<div id='show-overflow' class='ui-state-highlight ui-corner-all'>
+    <span class='ui-icon ui-icon-info'></span>
+    There are $num_hidden older comments that are not shown.
+    <button id='button-show-overflow'>Show hidden comments</button>
+</div>
+<div id='ticketcommentsoverflow' style='display:none'>
+HTML;
+  while (count($events) > 20) {
+    echo array_shift($events);
+  }
+  echo <<<HTML
+</div>
+<script type='text/javascript'>
+$('#button-show-overflow').click(function() {
+  $('#show-overflow').hide('blind');
+  $('#ticketcommentsoverflow').show('clip');
+  return false;
+});
+</script>
+HTML;
+
+}
+while (count($events)) {
+  echo array_shift($events);
+}
+
+
+if ($id != 'new') {
+?>
+<br style="clear:both">
+<button id="bottom-comment-button" class='mtrack-make-comment'>Add Comment</button>
+<?php
+}
+
+?>
+</form>
+<div id="confirmCancelDialog" style="display:none"
+    title="Are you sure?">
+  You've entered information into the form.
+  If you cancel, you will not be able to get it back.
+</div>
+<div id="noCommentDialog" style="display:none"
+    title="Please enter comment">
+  It seems you have not made any changes to the ticket,
+  and haven't entered any comments.
+</div>
+<div id="noSummaryDialog" style="display:none"
+    title="Please enter summary">
+  It seems you haven't entered a summary for the ticket.
+</div>
+<script type='text/javascript'>
+var formChanged = <?php echo $preview ? 'true' : 'false'; ?>;
+
+var viewblock;
+var view_off;
+var view_pos;
+var editblock;
+var edit_off;
+var edit_pos;
+
+function show_edit_form()
+{
+  viewblock.css('position', view_pos);
+  viewblock.css('top', view_off.top);
+
+  $("#issue-desc").hide();
+  $("#edit-issue-desc").show();
+  $("#edit-comment-parent").append($("#comment-area"));
+  $("#comment-submit-buttons").hide();
+  $("#comment-area").show();
+  $(".mtrack-make-comment").hide();
+  $("#description").focus();
+
+  editblock.show();
+  edit_off = editblock.offset();
+  edit_pos = editblock.css('position');
+  viewblock.hide();
+  compute_floats();
+}
+
+function compute_floats()
+{
+  if ($(viewblock).is(':visible')) {
+    if ($(this).scrollTop() > view_off.top) {
+      viewblock.css('position', 'fixed');
+      viewblock.css('top', '0px');
+      viewblock.addClass('button-float-floating');
+    } else {
+      viewblock.css('position', view_pos);
+      viewblock.css('top', view_off.top);
+      viewblock.removeClass('button-float-floating');
+    }
+  }
+  if ($(editblock).is(':visible')) {
+    if ($(this).scrollTop() > edit_off.top) {
+      editblock.css('position', 'fixed');
+      editblock.css('top', '0px');
+      editblock.addClass('button-float-floating');
+    } else if ($(this).scrollTop() < edit_off.top + editblock.height() - $(this).height()) {
+      editblock.css('position', 'fixed');
+      editblock.css('top', $(this).height() - editblock.outerHeight());
+      editblock.addClass('button-float-floating');
+    } else {
+      editblock.css('position', edit_pos);
+      editblock.css('top', edit_off.top);
+      editblock.removeClass('button-float-floating');
+    }
+  }
+}
+
+$(document).ready(function() {
+  viewblock = $('#tkt-view-button-block');
+  editblock = $('#tkt-edit-button-block');
+  view_off = viewblock.offset();
+  view_pos = viewblock.css('position');
+
+$(window).scroll(function () {
+  compute_floats();
+});
+
+
+$(".mtrack-edit-desc").click(
+  function() {
+    show_edit_form();
+    return false;
+  }
+);
+$("input[type=radio]").click(
+  function() {
+    if (this.value == 'fixed') {
+      $("#changelog-container").show();
+    } else {
+      $("#changelog-container").hide();
+    }
+  }
+);
+
+$(":input").change(function() {
+  formChanged = true;
+});
+$("textarea").keyup(function() {
+  // This is here because IE doesn't seem to reliably trigger the
+  // change event with textareas
+  formChanged = true;
+});
+$("#confirmCancelDialog").dialog({
+  autoOpen: false,
+  bgiframe: true,
+  resizable: false,
+  modal: true,
+  buttons: {
+    'Discard': function() {
+      $(this).dialog('close');
+      cancel_form_changes();
+    },
+    'Keep': function() {
+      $(this).dialog('close');
+    }
+  }
+});
+$("#noCommentDialog").dialog({
+  autoOpen: false,
+  bgiframe: true,
+  resizable: false,
+  modal: true,
+  buttons: {
+    'OK': function() {
+      $(this).dialog('close');
+      $("#comment").focus();
+    }
+  }
+});
+$("#noSummaryDialog").dialog({
+  autoOpen: false,
+  bgiframe: true,
+  resizable: false,
+  modal: true,
+  buttons: {
+    'OK': function() {
+      $(this).dialog('close');
+      $("#summary").focus();
+    }
+  }
+});
+
+function cancel_form_changes()
+{
+<?php
+  if ($preview) {
+?>
+    document.location.href = document.location.href;
+    return false;
+<?php
+  }
+?>
+  editblock.css('position', edit_pos);
+  editblock.css('top', edit_off.top);
+  editblock.hide();
+  viewblock.show();
+
+  $("#tktedit").each(function(){
+    // reset form
+    this.reset();
+  });
+  // notify asm select of change
+  $("select[multiple]").change();
+  $("#edit-issue-desc").hide();
+
+  $("#original-comment-parent").append($("#comment-area"));
+  $("#comment-submit-buttons").show();
+
+  <?php if ($id != 'new') { ?>
+  $("#comment-area").hide();
+  $(".mtrack-make-comment").show();
+  <?php } ?>
+  $("#issue-desc").show();
+  formChanged = false;
+  compute_floats();
+
+  return false;
+}
+
+$(".mtrack-edit-cancel").click(
+  function() {
+    if (formChanged) {
+      $("#confirmCancelDialog").dialog('open');
+      return false;
+    } else {
+      return cancel_form_changes();
+    }
+  }
+);
+$(".mtrack-make-comment").click(
+  function() {
+    show_edit_form();
+    $("#comment").focus();
+    return false;
+  }
+);
+
+$(".mtrack-button-submit").click(function(){
+
+    var id = '<?php echo $id ?>';
+
+    if ($("#summary").val() == '') {
+
+        $("#summary").addClass('error');
+        $("#noSummaryDialog").dialog('open');
+        return false;
+
+    } else {
+
+        if (formChanged == false && $("#comment").val() == '') {
+            $("#comment").addClass('error');
+            $("#noCommentDialog").dialog('open');
+            return false;
+        }
+
+    }
+
+});
+
+$("#comment").keydown(function(){
+    $("#comment").removeClass('error');
+});
+
+$("#summary").keydown(function(){
+    $("#summary").removeClass('error');
+});
+
+<?php
+if ($issue->tid == null) {
+?>
+$("#summary").focus();
+<?php
+}
+?>
+});
+</script>
+<?php
+
+mtrack_foot();
+
+function renderEditForm($issue, $params = array())
+{
+  global $id;
+  global $ABSWEB;
+  global $FIELDSET;
+
+  if (!isset($params['formname'])) {
+    $params['formname'] = 'tktedit';
+  } else if (!ctype_alpha($params['formname'])) {
+    throw new Exception("invalid form name");
+  }
+
+  /* compute allowed field values */
+  $allowed = array();
+
+  $C = new MTrackClassification;
+  $allowed['classification'] = $C->enumerate();
+
+  $P = new MTrackPriority;
+  $allowed['priority'] = $P->enumerate();
+
+  $S = new MTrackSeverity;
+  $allowed['severity'] = $S->enumerate();
+
+  $R = new MTrackResolution;
+  $allowed['resolution'] = $R->enumerate();
+
+  $r = array();
+  foreach (MTrackDB::q('select c.compid, c.name, p.name from components c left join components_by_project cbp on (c.compid = cbp.compid) left join projects p on (cbp.projid = p.projid) where deleted <> 1 order by c.name')
+      ->fetchAll(PDO::FETCH_NUM) as $row) {
+    if (strlen($row[2])) {
+      $row[1] .= " ($row[2])";
+    }
+    $r[$row[0]] = $row[1];
+  }
+  $allowed['component'] = $r;
+
+  $r = array();
+  foreach (MTrackDB::q(
+        'select mid, name from milestones where deleted <> 1
+        and completed is null order by (case when duedate is null then 1 else 0 end), duedate, name'
+        )->fetchAll(PDO::FETCH_NUM) as $row) {
+    $r[$row[0]] = $row[1];
+  }
+  foreach ($issue->getMilestones() as $mid => $name) {
+    if (!isset($r[$mid])) {
+      $r[$mid] = $name;
+    }
+  }
+  $allowed['milestone'] = $r;
+
+  // FIXME: workflow should be able to influence this list of users
+  $users = array();
+  $inactiveusers = array();
+  foreach (MTrackDB::q(
+      'select userid, fullname, active from userinfo order by userid'
+      )->fetchAll() as $row) {
+    if (strlen($row[1])) {
+      $disp = "$row[0] - $row[1]";
+    } else {
+      $disp = $row[0];
+    }
+    if ($row[2]) {
+      $users[$row[0]] = $disp;
+    } else {
+      $inactiveusers[$row[0]] = $disp;
+    }
+  }
+  $users[''] = 'nobody';
+  // allow for inactive users to show up if they're currently responsible
+  if (!isset($users[$issue->owner])) {
+    if (!isset($inactiveusers[$issue->owner])) {
+      $users[$issue->owner] = $issue->owner . ' (inactive)';
+    } else {
+      $users[$issue->owner] = $inactiveusers[$issue->owner] . ' (inactive)';
+    }
+  }
+  // last ditch to have it show the right info
+  if (!isset($users[$issue->owner])) {
+    $users[$issue->owner] = $issue->owner;
+  }
+  $allowed['owner'] = $users;
+
+  $html = "<input type='hidden' name='tid' value='" .
+    htmlentities($issue->tid === null ? 'new' : $issue->tid) . "'>\n";
+
+  /* render the form */
+  $col = 0;
+
+  foreach ($FIELDSET as $fsid => $fieldset) {
+    if (is_string($fsid)) {
+      $html .= "<fieldset id='$fsid'><legend>$fsid</legend>\n";
+    }
+
+    $html .= "<table class='fields'>";
+    $col = 0;
+    foreach ($fieldset as $propname => $info) {
+      if (isset($info['readonly'])) {
+        continue;
+      }
+      if (empty($info['ownrow'])) {
+        $info['ownrow'] = false;
+      }
+      $value = null;
+      switch ($propname) {
+        case 'keywords':
+          $value = join(' ', $issue->getKeywords());
+          break;
+        case 'milestone':
+          $value = $issue->getMilestones();
+          break;
+        case 'component':
+          $value = $issue->getComponents();
+          break;
+        case 'owner':
+          $value = $issue->owner;
+          if (!strlen($value)) {
+            $value = '';
+          }
+          break;
+        default:
+          if (isset($issue->$propname)) {
+            $value = $issue->$propname;
+          }
+      }
+      if (isset($info['condition']) && !$info['condition']) {
+        continue;
+      }
+
+      if (($info['ownrow'] && $col) || $col == 2) {
+        $html .= "</tr>\n";
+        $col = 0;
+      }
+      if ($col == 0) {
+        $html .= "<tr valign='top'>";
+      }
+      $col++;
+      if ($info['ownrow']) {
+        $html .= "<td colspan='4'>";
+      } else if ($info['type'] == 'multiselect') {
+        $html .= "<td colspan='2'>";
+      } else {
+        $html .= "<td>";
+      }
+
+      if ($value === null && isset($info['default'])) {
+        $value = $info['default'];
+      }
+
+      if ($info['type'] != 'multiselect') {
+        $html .= "<label for='$propname'>".
+          htmlentities($info['label'], ENT_QUOTES, 'utf-8').
+          ":</label>";
+      }
+
+      if ($info['ownrow']) {
+        $html .= "<br>\n";
+      } else if ($info['type'] != 'multiselect') {
+        $html .= "</td><td class='col$col'>";
+      }
+
+      switch ($info['type']) {
+        case 'text':
+          $size = empty($info['size']) ? "" : "size='$info[size]' ";
+          $html .= "<input id='$propname' name='$propname' ".
+            $size .
+            "value='".htmlentities($value, ENT_QUOTES, 'utf-8').
+            "'>";
+          break;
+
+        case 'multi':
+          $html .= "<textarea id='$propname' name='$propname' ".
+            "rows='$info[rows]' cols='$info[cols]' class='code'>".
+            htmlentities($value, ENT_QUOTES, 'utf-8').
+            "</textarea>\n";
+          break;
+
+        case 'wiki':
+          $srows = $info['rows'] + 1;
+          $html .= "<textarea id='$propname' name='$propname' ".
+            "style='height: {$srows}em' " .
+            "rows='$info[rows]' cols='$info[cols]' class='code wiki'>".
+            htmlentities($value, ENT_QUOTES, 'utf-8').
+            "</textarea>\n";
+          break;
+
+        case 'shortwiki':
+          $html .= "<textarea id='$propname' name='$propname' ".
+            "rows='$info[rows]' cols='$info[cols]' class='code wiki shortwiki'>"
+            . htmlentities($value, ENT_QUOTES, 'utf-8').
+            "</textarea>\n";
+          break;
+
+        case 'select':
+          if (isset($allowed[$propname])) {
+            $html .= mtrack_select_box($propname,
+                $allowed[$propname], $value);
+          } else {
+            $html .= mtrack_select_box($propname,
+                $info['options'], $value);
+          }
+          break;
+
+        case 'multiselect':
+          if (isset($allowed[$propname])) {
+            $html .= mtrack_multi_select_box($propname,
+                $info['label'] . " (select to add)",
+                $allowed[$propname], $value);
+          } else {
+            $html .= mtrack_multi_select_box($propname,
+                $info['label'] . " (select to add)",
+                $info['options'], $value);
+          }
+          break;
+      }
+
+      $html .= "</td>\n";
+
+      if ($info['ownrow']) {
+        $html .= "</tr>\n";
+        $col = 0;
+      }
+    }
+    $html .= "</table>\n";
+
+    if (is_string($fsid)) {
+      $html .= "</fieldset>\n";
+    }
+  }
+
+  $html .= "<fieldset id='action-container'><legend>Action</legend>\n";
+
+  // FIXME: workflow inspired actions listed here
+  if (!isset($_POST['action'])) {
+    $_POST['action'] = 'none';
+  }
+  if ($id != 'new') {
+    $html .= mtrack_radio('action', 'none', $_POST['action']);
+    $status = $issue->status == 'closed' ? $issue->resolution : $issue->status;
+    $html .= " <label for='none'>Leave status as $status</label><br>\n";
+
+    if ($issue->status != 'closed') {
+      $html .= mtrack_radio('action', 'accept', $_POST['action']);
+      $html .= " <label for='accept'>Accept ticket</label><br>\n";
+
+      $ST = new MTrackTicketState;
+      $ST = $ST->enumerate();
+      unset($ST['closed']);
+      unset($ST[$status]);
+      if (count($ST)) {
+        $html .= mtrack_radio('action', 'changestatus', $_POST['action']);
+        $html .= " <label for='changestatus'>Change status to:</label>";
+        $html .= mtrack_select_box('status', $ST, $issue->status);
+        $html .= "<br>\n";
+      }
+
+      $html .= mtrack_radio('action', 'fixed', $_POST['action']);
+      $html .= " <label for='fixed'>Resolve as fixed</label><br>\n";
+
+      $R = new MTrackResolution;
+      $resolutions = $R->enumerate();
+      unset($resolutions['fixed']);
+      $html .= mtrack_radio('action', 'resolve', $_POST['action']);
+      $html .= " <label for='resolve'>Resolve as:</label>";
+      $html .= mtrack_select_box('resolution', $resolutions);
+      $html .= "<br>\n";
+
+    } else {
+      $html .= mtrack_radio('action', 'reopen', $_POST['action']);
+      $html .= " <label for='reopen'>Reopen ticket</label><br>\n";
+    }
+    $html .= "<br>\n";
+  }
+
+  $spent = empty($_POST['spent']) ? '' : htmlentities($_POST['spent'], ENT_QUOTES, 'utf-8');
+  if (!strlen($spent)) {
+    $spent = '0';
+  }
+  $html .= "<label for='spent'>Log time spent (hours)</label> ";
+  $html .= "<input type='text' name='spent' value='$spent'><br>\n";
+
+  if ($id != 'new') {
+    $html .= MTrackAttachment::renderDeleteList("ticket:$issue->tid");
+    $html .= <<<HTML
+  <br>
+  <label for='attachments[]'>Select file(s) to be attached</label>
+  <input type='file' class='multi' name='attachments[]'>
+HTML;
+  }
+  $html .= "</fieldset>";
+  $html .= "<fieldset id='comment-container'><legend>Comment</legend>\n";
+
+  $html .= <<<HTML
+<textarea name='comment' id="comment"
+  class="wiki shortwiki" rows="5" cols="78">
+HTML;
+  if (isset($_POST['comment'])) {
+    $html .= htmlentities($_POST['comment'], ENT_QUOTES, 'utf-8');
+  }
+  $html .= "</textarea>";
+
+  $html .= "</fieldset>";
+  $html .= MTrackCaptcha::emit('ticket');
+
+  $html .= <<<HTML
+    <div id='tkt-edit-button-block' class='button-float'>
+    <button class='mtrack-button-submit' type="submit" name="preview">Preview</button>
+    <button class='mtrack-button-submit' type="submit" name="apply">Submit changes</button>
+    <button class='mtrack-edit-cancel' type="submit" name="cancel">Cancel</button>
+HTML;
+
+  if ($id != 'new') {
+    $html .= <<<HTML
+<button class='mtrack-make-comment'>Add Comment</button>
+HTML;
+  }
+
+  $html .= "</div>";
+
+  return $html;
+}
diff --git a/web/timeline.php b/web/timeline.php
new file mode 100644 (file)
index 0000000..100a778
--- /dev/null
@@ -0,0 +1,12 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+MTrackACL::requireAllRights('Timeline', 'read');
+mtrack_head("Timeline");
+
+mtrack_render_timeline();
+
+mtrack_foot();
+
diff --git a/web/user.php b/web/user.php
new file mode 100644 (file)
index 0000000..50f657a
--- /dev/null
@@ -0,0 +1,328 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+
+$user = mtrack_get_pathinfo();
+if ($user === null && isset($_GET['user'])) {
+  $user = $_GET['user'];
+}
+if (!strlen(trim($user))) {
+  throw new Exception("No user name provided");
+}
+$user = mtrack_canon_username($user);
+
+$me = mtrack_canon_username(MTrackAuth::whoami());
+if (!empty($_REQUEST['edit'])) {
+  if (MTrackACL::hasAllRights('User', 'modify')) {
+    // can edit all
+  } else if ($me != 'anonymous' && $me === $user) {
+    // Can edit my own bits
+    MTrackACL::requireAllRights('User', 'read');
+  } else {
+    // already checked this above, but we want it to trigger the privilege
+    // error here
+    MTrackACL::requireAllRights('User', 'modify');
+  }
+
+  if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+    $http_auth = MTrackAuth::getMech('MTrackAuth_HTTP');
+    if ($http_auth && !isset($_SERVER['REMOTE_USER'])) {
+      if ($_POST['passwd1'] != $_POST['passwd2']) {
+        throw new Exception("passwords don't match!");
+      }
+    }
+
+    $data = MTrackDB::q('select * from userinfo where userid = ?', $user)
+              ->fetchAll(PDO::FETCH_ASSOC);
+    if (isset($data[0])) {
+      // Updating
+      if (MTrackACL::hasAllRights('User', 'modify')) {
+        if (isset($_POST['active'])) {
+          $active = $_POST['active'] == 'on' ? '1' : '0';
+        } else {
+          $active = '0';
+        }
+        MTrackDB::q('update userinfo set fullname = ?, email = ?, timezone = ?, active = ?, sshkeys = ? where userid = ?', $_POST['fullname'], $_POST['email'], $_POST['timezone'], $active, $_POST['keys'], $user);
+      } else {
+        MTrackDB::q('update userinfo set fullname = ?, email = ?, timezone = ?, sshkeys = ? where userid = ?', $_POST['fullname'], $_POST['email'], $_POST['timezone'], $_POST['keys'], $user);
+      }
+    } else {
+      MTrackDB::q('insert into userinfo (active, fullname, email, timezone, sshkeys, userid) values (1, ?, ?, ?, ?, ?)', $_POST['fullname'], $_POST['email'], $_POST['timezone'], $_POST['keys'], $user);
+    }
+
+    if (MTrackACL::hasAllRights('User', 'modify')) {
+      MTrackDB::q('delete from useraliases where userid = ?', $user);
+      foreach (preg_split("/\r?\n/", $_POST['aliases']) as $alias) {
+        if (!strlen(trim($alias))) {
+          continue;
+        }
+        MTrackDB::q('insert into useraliases (userid, alias) values (?, ?)',
+          $user, $alias);
+      }
+
+      $user_class = MTrackAuth::getUserClass($user);
+      if (isset($_POST['user_role']) && $_POST['user_role'] != $user_class) {
+        MTrackConfig::set('user_classes', $user, $_POST['user_role']);
+        MTrackConfig::save();
+      }
+    }
+    $http_auth = MTrackAuth::getMech('MTrackAuth_HTTP');
+    if ($http_auth && !isset($_SERVER['REMOTE_USER'])) {
+      // Allow changing their password
+      $http_auth->setUserPassword($user, $_POST['passwd1']);
+    }
+    header("Location: {$ABSWEB}user.php?user=" . urlencode($user));
+    exit;
+  }
+
+} else {
+  MTrackACL::requireAllRights('User', 'read');
+}
+
+mtrack_head("User $user");
+
+$data = MTrackDB::q('select * from userinfo where userid = ?', $user)->fetchAll(PDO::FETCH_ASSOC);
+if (isset($data[0])) {
+  $data = $data[0];
+} else {
+  $data = null;
+}
+
+$display = $user;
+
+if (strlen($data['fullname'])) {
+  $display .= " - " . $data['fullname'];
+}
+
+echo "<h1>", htmlentities($display, ENT_QUOTES, 'utf-8'), "</h1>";
+echo "<div class='userinfo'>";
+echo mtrack_username($user, array(
+  'no_name' => true,
+  'size' => 128
+));
+echo "<a href='mailto:$data[email]'>$data[email]</a><br>\n";
+
+if (empty($_GET['edit'])) {
+  $aliases = MTrackDB::q('select alias from useraliases where userid = ? order by alias', $user)->fetchAll(PDO::FETCH_COLUMN, 0);
+  if (count($aliases)) {
+    echo "<h2>Aliases</h2><ul>\n";
+    foreach ($aliases as $alias) {
+      echo "<li>", htmlentities($alias, ENT_QUOTES, 'utf-8'), "</li>\n";
+    }
+    echo "</ul>\n";
+  }
+}
+
+echo "</div>";
+
+if (empty($_GET['edit'])) {
+  $me = mtrack_canon_username(MTrackAuth::whoami());
+  if ($me != 'anonymous' && $me === $user) {
+    $label = 'Edit my details';
+  } else if (MTrackACL::hasAnyRights('User', 'modify')) {
+    $label = 'Edit user details';
+  } else {
+    $label = null;
+  }
+  if ($label !== null) {
+    echo "<form method='get' action='{$ABSWEB}user.php'>" .
+      "<input type='hidden' name='user' value='" . $user . "'>" .
+      "<input type='hidden' name='edit' value='1'>" .
+      "<button type='submit'>$label</button></form>";
+  }
+
+  if (MTrackACL::hasAnyRights('Timeline', 'read')) {
+    echo "<h2>Recent Activity</h2>\n";
+    mtrack_render_timeline($user);
+  }
+} else {
+
+  echo "<form method='post' action='{$ABSWEB}user.php?user=" .
+    urlencode($user) . "'>\n";
+
+  $fullname = htmlentities(
+    isset($data['fullname']) ? $data['fullname'] : '',
+    ENT_QUOTES, 'utf-8');
+  $email = htmlentities(
+    isset($data['email']) ? $data['email'] : '',
+    ENT_QUOTES, 'utf-8');
+  $timezone = htmlentities(
+    isset($data['timezone']) ? $data['timezone'] : '',
+    ENT_QUOTES, 'utf-8');
+
+  echo <<<HTML
+<input type='hidden' name='edit' value='1'>
+
+<fieldset id='userinfo-container'>
+  <legend>User Information</legend>
+  <table>
+    <tr valign='top'>
+      <td>
+        <label for='fullname'>Full name</label>
+      </td>
+      <td>
+        <input type='text' name='fullname' size='64' value='$fullname'>
+      </td>
+    </tr>
+    <tr valign='top'>
+      <td>
+        <label for='email'>Email</label>
+      </td>
+      <td>
+        <input type='text' name='email' size='64' value='$email'><br>
+        <em>We use this with <a href='http://gravatar.com'>Gravatar</a>
+          to obtain your avatar image throughout mtrack</em>
+      </td>
+    </tr>
+    <tr valign='top'>
+      <td>
+        <label for='timezone'>Timezone</label>
+      </td>
+      <td>
+        <input type='text' name='timezone' size='24' value='$timezone'><br>
+        <em>We use this to show times in your preferred timezone</em>
+      </td>
+    </tr>
+HTML;
+  if (MTrackACL::hasAllRights('User', 'modify')) {
+    if (isset($data['active'])) {
+      $active = (int)$data['active'];
+    } else {
+      $active = 0;
+    }
+    if ($active) {
+      $active = " checked='checked'";
+    }
+    echo <<<HTML
+    <tr valign='top'>
+      <td>
+        <label for='active'>Active?</label>
+      </td>
+      <td>
+        <input type='checkbox' name='active' $active><br>
+        <em>Active users are shown in the Responsible users list when editing tickets</em>
+      </td>
+    </tr>
+HTML;
+
+    $user_class = MTrackAuth::getUserClass($user);
+    $user_class_roles = array();
+    foreach (MTrackConfig::getSection('user_class_roles') as $role => $rights) {
+      $user_class_roles[$role] = $role;
+    }
+    $role_select = mtrack_select_box('user_role', $user_class_roles,
+                    $user_class);
+    echo <<<HTML
+    <tr valign='top'>
+      <td>
+        <label for='active'>Role</label>
+      </td>
+      <td>
+        $role_select<br>
+        <em>The role defines which actions this user can carry out in mtrack</em>
+      </td>
+    </tr>
+HTML;
+
+  }
+
+  $http_auth = MTrackAuth::getMech('MTrackAuth_HTTP');
+  if ($http_auth && !isset($_SERVER['REMOTE_USER'])) {
+
+    if ($me == $user) {
+      $your = "your";
+    } else {
+      $your = "this users";
+    }
+
+    echo <<<HTML
+    <tr>
+      <td>
+        <label for='passwd1'>New Password</label>
+      </td>
+      <td>
+        <input type="password" name="passwd1"><br>
+        <em>Enter $your new password</em>
+      </td>
+    </tr>
+    <tr>
+      <td>
+        <label for='passwd2'>Confirm Password</label>
+      </td>
+      <td>
+        <input type="password" name="passwd2"><br>
+        <em>Confirm $your new password</em>
+      </td>
+    </tr>
+HTML;
+
+  }
+
+  echo <<<HTML
+  </table>
+</fieldset>
+HTML;
+
+  $groups = MTrackAuth::getGroups($user);
+  echo <<<HTML
+<fieldset id='userinfo-groups'>
+  <legend>Groups</legend>
+  <em>This user is a member of the following groups</em>
+  <ul>
+HTML;
+  foreach ($groups as $group) {
+    echo "<li>" . htmlentities($group, ENT_QUOTES, 'utf-8') . "</li>\n";
+  }
+  echo <<<HTML
+  </ul>
+</fieldset>
+HTML;
+
+  if (MTrackACL::hasAllRights('User', 'modify')) {
+
+    $aliases = MTrackDB::q('select alias from useraliases where userid = ? order by alias', $user)->fetchAll(PDO::FETCH_COLUMN, 0);
+    $atext = '';
+    foreach ($aliases as $alias) {
+      $atext .= htmlentities($alias, ENT_QUOTES, 'utf-8') . "\n";
+    }
+
+    echo <<<HTML
+<fieldset id='userinfo-container'>
+  <legend>Aliases</legend>
+  <em>This user is also known by the following identities (one per line) when
+  assessing changes in the various repositories</em><br>
+  <textarea name='aliases' cols='64' rows='10'>$atext</textarea>
+</fieldset>
+
+HTML;
+
+  }
+
+  echo <<<HTML
+  </table>
+</fieldset>
+HTML;
+
+  $keytext = htmlentities($data['sshkeys'], ENT_QUOTES, 'utf-8');
+  echo <<<HTML
+<fieldset id='sshkey-container'>
+  <legend>SSH Keys</legend>
+  <em>The repositories created and managed by mtrack are served over SSH.
+    Access is enabled only based on public SSH keys, not passwords.
+    In order to check code in or out, you must provide one or more
+    keys.  Paste in the public key(s) you want to use below, one per line.
+  </em><br>
+  <textarea name='keys' cols='64' rows='10'>$keytext</textarea>
+</fieldset>
+
+HTML;
+
+  echo <<<HTML
+
+  <button>Save Changes</button>
+</form>
+HTML;
+}
+
+mtrack_foot();
diff --git a/web/wiki.php b/web/wiki.php
new file mode 100644 (file)
index 0000000..70e96cf
--- /dev/null
@@ -0,0 +1,429 @@
+<?php # vim:ts=2:sw=2:et:
+/* For licensing and copyright terms, see the file named LICENSE */
+
+include '../inc/common.php';
+#error_reporting(E_ALL | E_NOTICE);
+
+$pi = mtrack_get_pathinfo();
+if (empty($pi)) {
+  $pi = "WikiStart";
+}
+
+$edit = isset($_REQUEST['edit']) ? (int)$_REQUEST['edit'] : null;
+$message = null;
+$conflicted = false;
+
+function is_content_conflicted($content)
+{
+  if (preg_match("/^(<+)\s+(mine|theirs|original)\s*$/m", $content)) {
+    return true;
+  }
+  return false;
+}
+function normalize_text($text) {
+  return rtrim($text) . "\n";
+}
+
+
+if ($pi !== null) {
+  $doc = MTrackWikiItem::loadByPageName($pi);
+  if ($doc) {
+    MTrackACL::requireAnyRights("wiki:$doc->pagename",
+      $edit ? 'modify' : 'read');
+  } else {
+    MTrackACL::requireAnyRights("wiki:$pi",
+      $edit ? 'modify' : 'read');
+  }
+  if ($doc === null && $edit) {
+    $doc = new MTrackWikiItem($pi);
+    $doc->content = " = $pi =\n";
+  }
+
+  if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+
+    if (isset($_POST['cancel'])) {
+      header("Location: ${ABSWEB}wiki.php/$pi");
+      exit;
+    }
+    if (!MTrackCaptcha::check('wiki')) {
+      $message = 'CAPTCHA failed, please try again';
+    } else if (isset($_POST['save'])) {
+      /* to avoid annoying "you lose because someone else edited" errors,
+       * we compute the diff from the original content we had, and apply
+       * that to the current content of the object */
+
+      $saved = false;
+
+      $orig = base64_decode($_POST['orig']);
+      $content = $_POST['content'];
+
+      /* for consistency, we always want a newline at the end, otherwise
+       * we can end up with some weird output from diff3 */
+      $orig = normalize_text($orig);
+      $content = normalize_text($content);
+      $doc->content = normalize_text($doc->content);
+      $conflicted = is_content_conflicted($content);
+      $tempdir = sys_get_temp_dir();
+
+      if (!$conflicted) {
+        $ofile = tempnam($tempdir, "mtrack");
+        $nfile = tempnam($tempdir, "mtrack");
+        $tfile = tempnam($tempdir, "mtrack");
+        $pfile = tempnam($tempdir, "mtrack");
+        $diff3 = MTrackConfig::get('tools', 'diff3');
+        if (empty($diff3)) {
+          $diff3 = 'diff3';
+        }
+
+        file_put_contents($ofile, $orig);
+        file_put_contents($nfile, $content);
+        file_put_contents($tfile, $doc->content);
+
+        if (PHP_OS == 'SunOS') {
+          exec("($diff3 -X $nfile $ofile $tfile ; echo '1,\$p') | ed - $nfile > $pfile",
+            $output = array(), $retval = 0);
+        } else {
+          exec("$diff3 -mX --label mine $nfile --label original $ofile --label theirs $tfile > $pfile",
+            $output = array(), $retval = 0);
+        }
+
+        if ($retval == 0) {
+          /* see if there were merge conflicts */
+          $content = '';
+          $mine = preg_quote($nfile, '/');
+          $theirs = preg_quote($tfile, '/');
+          $orig = preg_quote($ofile, '/');
+          $content = file_get_contents($pfile);
+
+          if (PHP_OS == 'SunOS') {
+            $content = str_replace($nfile, 'mine', $content);
+            $content = str_replace($ofile, 'original', $content);
+            $content = str_replace($tfile, 'theirs', $content);
+          }
+        }
+        unlink($ofile);
+        unlink($nfile);
+        unlink($tfile);
+        unlink($pfile);
+
+        $conflicted = is_content_conflicted($content);
+      }
+
+      /* keep the merged version for editing purposes */
+      $_POST['content'] = $content;
+      /* our concept of the the original content is now what
+       * is currently saved */
+      $_POST['orig'] = base64_encode($doc->content);
+
+      if ($conflicted) {
+        $message = "Conflicting edits were detected; please correct them before saving";
+      } else {
+        $doc->content = $content;
+        try {
+          $cs = MTrackChangeset::begin("wiki:$pi", $_POST['comment']);
+          $doc->save($cs);
+          if (is_array($_FILES['attachments'])) {
+            foreach ($_FILES['attachments']['name'] as $fileid => $name) {
+              $do_attach = false;
+              switch ($_FILES['attachments']['error'][$fileid]) {
+                case UPLOAD_ERR_OK:
+                  $do_attach = true;
+                  break;
+                case UPLOAD_ERR_NO_FILE:
+                  break;
+                case UPLOAD_ERR_INI_SIZE:
+                case UPLOAD_ERR_FORM_SIZE:
+                  $message = "Attachment(s) exceed the upload file size limit";
+                  break;
+                case UPLOAD_ERR_PARTIAL:
+                case UPLOAD_ERR_CANT_WRITE:
+                  $message = "Attachment file upload failed";
+                  break;
+                case UPLOAD_ERR_NO_TMP_DIR:
+                  $message = "Server configuration prevents upload due to missing temporary dir";
+                  break;
+                case UPLOAD_ERR_EXTENSION:
+                  $message = "An extension prevented an upload from running";
+              }
+              if ($message !== null) {
+                throw new Exception($message);
+              }
+              if ($do_attach) {
+                MTrackAttachment::add("wiki:$pi",
+                  $_FILES['attachments']['tmp_name'][$fileid],
+                  $_FILES['attachments']['name'][$fileid],
+                  $cs);
+              }
+            }
+          }
+          MTrackAttachment::process_delete("wiki:$pi", $cs);
+          $cs->commit();
+          MTrackWikiItem::commitNow();
+          $saved = true;
+        } catch (Exception $e) {
+          $message = $e->getMessage();
+        }
+      }
+
+      if ($saved) {
+        /* we're good; go back to view mode */
+        header("Location: ${ABSWEB}wiki.php/$pi");
+        exit;
+      }
+    }
+  }
+}
+
+/* now just render */
+
+$title = $pi;
+if ($edit) {
+  $title .= " (edit)";
+}
+mtrack_head($title);
+$ppi = htmlentities($pi, ENT_QUOTES, 'utf-8');
+$editurl = $ABSWEB . "wiki.php/$pi";
+
+$nav = array();
+
+if (!$edit && MTrackACL::hasAnyRights("wiki:$pi", 'modify')) {
+  $nav["$editurl?edit=1"] = 'Edit this Page';
+}
+
+if ($doc) {
+  $nav["/log.php/default/wiki/$doc->filename"] = "Page History";
+}
+
+$nav["/wiki.php?action=list"] = "Help &amp; Title Index";
+$nav["/wiki.php?action=recent"] = "Recent Changes";
+
+if ($doc && $doc->file) {
+  $evt = $doc->file->getChangeEvent();
+  $reason = $evt->changelog;
+  if (!strlen($evt->changelog)) {
+    $reason = 'Changed';
+  }
+  $reason = htmlentities($reason, ENT_QUOTES, 'utf-8');
+  echo "<div id='wikilastchange'>",
+    mtrack_username($evt->changeby, array('no_name' => true,
+    'class' => 'wikilastchange')),
+    "$reason by ",
+    mtrack_username($evt->changeby, array('no_image' => true)), " ",
+    mtrack_date($evt->ctime),
+    "</div>\n";
+}
+
+echo mtrack_nav("wikinav", $nav);
+
+if (strlen($message)) {
+  echo "<br><div class='ui-state-error ui-corner-all'>" .
+    "<span class='ui-icon ui-icon-alert'></span>\n" .
+    htmlentities($message, ENT_QUOTES, 'utf-8') .
+    "</div>";
+}
+
+if (count($_GET) == 0 && ($doc === null || strlen($doc->content) == 0)) {
+  if (MTrackACL::hasAnyRights("wiki:$pi", 'create')) {
+    echo "Wiki page $ppi doesn't exist, would you like to create it?<br>";
+
+    echo <<<HTML
+<form name="launchwikiedit" method="GET" action="$editurl">
+<input type="hidden" name="edit" value="1"/>
+<button type="submit">Edit this page</button>
+</form>
+HTML;
+  } else {
+    echo "Wiki page $ppi doesn't exist.<br>";
+  }
+
+} elseif ($edit) {
+  echo "<h1>Editing $ppi</h1>";
+  echo "<a href=\"{$ABSWEB}/help.php/WikiFormatting\" target=\"_blank\">Wiki Formatting</a> (opens in a new window)<br>\n";
+
+  $orig_content = isset($_POST['orig']) ? $_POST['orig']
+                    : base64_encode($doc->content);
+  $content = isset($_POST['content']) ? $_POST['content'] : $doc->content;
+  $comment = isset($_POST['comment']) ? $_POST['comment'] : '';
+  $comment = htmlentities($comment, ENT_QUOTES, 'utf-8');
+
+  if (isset($_POST['preview'])) {
+    echo "<div class='wikipreview'>" .
+      MTrackWiki::format_to_html($content) . "</div>";
+  }
+
+  echo <<<HTML
+<form name="wikiedit" method="POST" action="$editurl" enctype='multipart/form-data'>
+<input type="hidden" name="edit" value="1"/>
+<input type="hidden" name="orig" value="$orig_content"/>
+HTML;
+
+    if ($conflicted) {
+      echo "<input type='hidden' name='conflicted' value='1'/>";
+    }
+
+    echo <<<HTML
+<textarea name="content" class="wiki"
+  rows="36" cols="78" style="width:100%;">$content</textarea>
+<fieldset>
+  <legend>Attachments</legend>
+HTML;
+    echo MTrackAttachment::renderDeleteList("wiki:$pi");
+    echo <<<HTML
+  <label for='attachments[]'>Select file(s) to be attached</label>
+  <input type='file' class='multi' name='attachments[]'>
+</fieldset>
+<fieldset id="changeinfo">
+  <legend>Change Information</legend>
+  <div class="field"><label>Comment about the change:<br/>
+    <input type="text" name="comment" size="60" value="$comment"/>
+  </label></div>
+HTML;
+    echo MTrackCaptcha::emit('wiki');
+    echo <<<HTML
+  <div class="buttons">
+    <button type="submit" name="preview">Preview</button>
+    <button type="submit" name="save">Save changes</button>
+    <button type="submit" name="cancel">Cancel</button>
+  </div>
+</form>
+
+HTML;
+
+} else {
+  $action = isset($_GET['action']) ? $_GET['action'] : 'view';
+
+  switch ($action) {
+    case 'view':
+      echo MTrackWiki::format_to_html($doc->content);
+      echo MTrackAttachment::renderList("wiki:$pi");
+      if (MTrackACL::hasAnyRights("wiki:$doc->pagename", 'modify')) {
+        echo <<<HTML
+<form name="launchwikiedit" method="GET" action="$editurl">
+<input type="hidden" name="edit" value="1"/>
+<button type="submit">Edit this page</button>
+</form>
+HTML;
+      }
+      break;
+
+    case 'list':
+      echo "<h1>Help topics by Title</h1>\n";
+      $htree = array();
+
+      function build_help_tree(&$tree, $dir) {
+        foreach (scandir($dir) as $ent) {
+          if ($ent[0] == '.') {
+            continue;
+          }
+          $full = $dir . DIRECTORY_SEPARATOR . $ent;
+          if (is_dir($full)) {
+            $kid = array();
+            build_help_tree($kid, $full);
+            $tree[$ent] = $kid;
+          } else {
+            $tree[$ent] = array();
+          }
+        }
+      }
+      function emit_tree($root, $parent, $phppage)
+      {
+        global $ABSWEB;
+
+        if (strlen($parent)) {
+          echo "<ul>\n";
+        } else {
+          echo "<ul class='wikitree'>\n";
+        }
+        $knames = array_keys($root);
+        usort($knames, 'strnatcasecmp');
+        foreach ($knames as $key) {
+          $kids = $root[$key];
+          $n = htmlentities($key, ENT_QUOTES, 'utf-8');
+          echo "<li>";
+          if (count($kids)) {
+            echo $n;
+            emit_tree($kids, "$parent$key/", $phppage);
+          } else {
+            echo "<a href=\"${ABSWEB}$phppage/$parent$n\">$n</a>";
+          }
+          echo "</li>\n";
+        }
+        echo "</ul>\n";
+      }
+
+      build_help_tree($htree, dirname(__FILE__) . '/../defaults/help');
+      emit_tree($htree, '', 'help.php');
+
+      echo "<h1>Wiki pages by Title</h1>\n";
+      /* get the page names into a tree format */
+      $tree = array();
+      $root = MTrackWikiItem::getRepoAndRoot($repo);
+      $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
+
+      function build_tree(&$tree, $repo, $dir, $suf) {
+        $items = $repo->readdir($dir);
+        foreach ($items as $file) {
+          $label = basename($file->name);
+          if ($file->is_dir) {
+            $kid = array();
+            build_tree($kid, $repo, $file->name, $suf);
+            $tree[$label] = $kid;
+          } else {
+            if ($suf && substr($label, -strlen($suf)) == $suf) {
+              $label = substr($label, 0, strlen($label) - strlen($suf));
+            }
+            $tree[$label] = array();
+          }
+        }
+      }
+
+      build_tree($tree, $repo, $root, $suf);
+
+      emit_tree($tree, '', 'wiki.php');
+
+      echo <<<HTML
+<script type='text/javascript'>
+$(document).ready(function(){
+  $('ul.wikitree').treeview({
+    collapsed: true,
+    persist: "location"
+  });
+});
+</script>
+HTML;
+
+      break;
+
+    case 'recent':
+      echo <<<HTML
+<h1>Recently Edited Wiki Pages</h1>
+<table class="history">
+  <tr>
+    <th>Page</th>
+    <th>Date</th>
+    <th>Who</th>
+    <th>Reason</th>
+  </tr>
+HTML;
+      $root = MTrackWikiItem::getRepoAndRoot($repo);
+      foreach ($repo->history(null, 100) as $e) {
+        $d = mtrack_date($e->ctime);
+        list($page) = $e->files;
+        if (strlen($root)) {
+          $page = substr($page, strlen($root)+1);
+        }
+        $author = mtrack_username($e->changeby);
+        $reason = htmlentities($e->changelog, ENT_QUOTES, 'utf-8');
+
+        echo "<tr><td><a href=\"${ABSWEB}wiki.php/$page\">$page</a></td><td>$d</td><td>$author</td><td>$reason</td></tr>\n";
+      }
+
+      echo <<<HTML
+</table>
+HTML;
+
+      break;
+
+  }
+}
+mtrack_foot();