From 29cac3c0e01987683ce5d500381a30d9cc1c4936 Mon Sep 17 00:00:00 2001 From: Alan Knowles Date: Sun, 6 Mar 2011 10:27:19 +0800 Subject: [PATCH] final move of files --- Auth/COPYING | 202 ++ Auth/OpenID.php | 552 ++++ Auth/OpenID/AX.php | 1023 ++++++++ Auth/OpenID/Association.php | 613 +++++ Auth/OpenID/BigMath.php | 471 ++++ Auth/OpenID/Consumer.php | 2230 +++++++++++++++++ Auth/OpenID/CryptUtil.php | 109 + Auth/OpenID/DatabaseConnection.php | 131 + Auth/OpenID/DiffieHellman.php | 113 + Auth/OpenID/Discover.php | 548 ++++ Auth/OpenID/DumbStore.php | 100 + Auth/OpenID/Extension.php | 62 + Auth/OpenID/FileStore.php | 618 +++++ Auth/OpenID/HMAC.php | 99 + Auth/OpenID/Interface.php | 197 ++ Auth/OpenID/KVForm.php | 112 + Auth/OpenID/MemcachedStore.php | 208 ++ Auth/OpenID/Message.php | 920 +++++++ Auth/OpenID/MySQLStore.php | 78 + Auth/OpenID/Nonce.php | 109 + Auth/OpenID/PAPE.php | 301 +++ Auth/OpenID/Parse.php | 352 +++ Auth/OpenID/PostgreSQLStore.php | 113 + Auth/OpenID/SQLStore.php | 569 +++++ Auth/OpenID/SQLiteStore.php | 71 + Auth/OpenID/SReg.php | 521 ++++ Auth/OpenID/Server.php | 1760 +++++++++++++ Auth/OpenID/ServerRequest.php | 37 + Auth/OpenID/TrustRoot.php | 462 ++++ Auth/OpenID/URINorm.php | 249 ++ Auth/Yadis/HTTPFetcher.php | 147 ++ Auth/Yadis/Manager.php | 529 ++++ Auth/Yadis/Misc.php | 59 + Auth/Yadis/ParanoidHTTPFetcher.php | 226 ++ Auth/Yadis/ParseHTML.php | 259 ++ Auth/Yadis/PlainHTTPFetcher.php | 249 ++ Auth/Yadis/XML.php | 374 +++ Auth/Yadis/XRDS.php | 478 ++++ Auth/Yadis/XRI.php | 234 ++ Auth/Yadis/XRIRes.php | 72 + Auth/Yadis/Yadis.php | 382 +++ MTrack/ACL.php | 600 +++++ MTrack/Attachment.php | 217 ++ MTrack/Auth.php | 269 ++ MTrack/Captcha.php | 29 + MTrack/Captcha/Recaptcha.php | 81 + MTrack/Changeset.php | 110 + MTrack/Classification.php | 14 + MTrack/CommitCheck/BlankLines.php | 48 + MTrack/CommitCheck/NoEmptyLogMessage.php | 18 + MTrack/CommitCheck/PhpLint.php | 64 + MTrack/CommitCheck/RequiresTimeReference.php | 22 + MTrack/CommitCheck/SingleIssue.php | 32 + MTrack/CommitCheck/UnixLineBreak.php | 47 + MTrack/CommitCheck/Wiki.php | 41 + MTrack/CommitChecker.php | 341 +++ MTrack/CommitHookChangeEvent.php | 21 + MTrack/Component.php | 117 + MTrack/Config.php | 161 ++ MTrack/DB.php | 132 + MTrack/DBSchema.php | 80 + MTrack/DBSchema/Generic.php | 107 + MTrack/DBSchema/SQLite.php | 105 + MTrack/DBSchema/Table.php | 44 + MTrack/DBSchema/mysql.php | 156 ++ MTrack/DBSchema/pgsql.php | 69 + MTrack/DataObjects/Event.php | 469 ++++ MTrack/DataObjects/Userinfo.php | 104 + MTrack/Enumeration.php | 84 + MTrack/Exception/Authorization.php | 8 + MTrack/Exception/DB.php | 0 MTrack/Exception/Veto.php | 11 + MTrack/Interface/Auth.php | 50 + MTrack/Interface/Captcha.php | 11 + MTrack/Interface/CommitHookBridge.php | 34 + MTrack/Interface/CommitHookBridge2.php | 8 + MTrack/Interface/CommitListener.php | 9 + MTrack/Interface/DBExtension.php | 8 + MTrack/Interface/DBSchema_Driver.php | 9 + MTrack/Interface/IssueListener.php | 21 + MTrack/Interface/SearchEngine.php | 10 + MTrack/Interface/WikiLinkHandler.php | 36 + MTrack/Issue.php | 666 +++++ MTrack/Keyword.php | 58 + MTrack/Milestone.php | 380 +++ MTrack/Priority.php | 14 + MTrack/Project.php | 96 + MTrack/Repo.php | 518 ++++ MTrack/Report.php | 642 +++++ MTrack/Resolution.php | 14 + MTrack/SCM.php | 265 ++ MTrack/SCM/Git/CommitHookBridge.php | 110 + MTrack/SCM/Git/Event.php | 127 + MTrack/SCM/Git/File.php | 120 + MTrack/SCM/Git/Repo.php | 363 +++ MTrack/SCM/Git/WorkingCopy.php | 68 + MTrack/SCM/Hg.php | 476 ++++ MTrack/SCM/Svn.php | 474 ++++ MTrack/SCM/Svn/CommitHookBridge.php | 60 + MTrack/SCMAnnotation.php | 30 + MTrack/SCMEvent.php | 50 + MTrack/SCMFile.php | 42 + MTrack/SCMFileEvent.php | 41 + MTrack/SCMWorkingCopy.php | 39 + MTrack/SearchDB.php | 74 + MTrack/SearchResult.php | 17 + MTrack/Severity.php | 16 + MTrack/Snippet.php | 72 + MTrack/SyntaxHighlight.php | 133 + MTrack/TicketState.php | 13 + MTrack/Ticket_CustomField.php | 82 + MTrack/Ticket_CustomFields.php | 175 ++ MTrack/UUID.php | 158 ++ MTrack/Watch.php | 501 ++++ MTrack/Wiki.php | 99 + MTrack/Wiki/HTMLFormatter.php | 948 +++++++ MTrack/Wiki/Item.php | 146 ++ MTrack/Wiki/OneLinerFormatter.php | 32 + MTrack/Wiki/Parser.php | 143 ++ MTrack/auth/http.php | 330 +++ MTrack/auth/openid.php | 67 + MTrack/cache.php | 155 ++ MTrack/hyperlight/cpp.php | 87 + MTrack/hyperlight/csharp.php | 79 + MTrack/hyperlight/css.php | 67 + MTrack/hyperlight/hyperlight.php | 1033 ++++++++ MTrack/hyperlight/iphp.php | 14 + MTrack/hyperlight/javascript.php | 46 + MTrack/hyperlight/perl.php | 60 + MTrack/hyperlight/php.php | 63 + MTrack/hyperlight/preg_helper.php | 170 ++ MTrack/hyperlight/python.php | 64 + MTrack/hyperlight/shell.php | 44 + MTrack/hyperlight/vb.php | 107 + MTrack/hyperlight/vibrant-ink.css | 50 + MTrack/hyperlight/wezterm.css | 90 + MTrack/hyperlight/wiki.php | 38 + MTrack/hyperlight/xml.php | 55 + MTrack/hyperlight/zenburn.css | 64 + MTrack/search/lucene.php | 980 ++++++++ MTrack/search/solr.php | 112 + MTrack/web.php | 816 ++++++ MTrackWeb.php | 202 ++ MTrackWeb/Admin.php | 18 + MTrackWeb/Attachment.php | 65 + MTrackWeb/Avatar.php | 161 ++ MTrackWeb/Browse.php | 62 + MTrackWeb/Changeset.php | 260 ++ MTrackWeb/CreateSchema.php | 30 + MTrackWeb/Cron/send-notifications.php | 844 +++++++ MTrackWeb/Cron/update-search-index.php | 159 ++ MTrackWeb/DataObjects/Acl.php | 25 + MTrackWeb/DataObjects/Attachments.php | 25 + MTrackWeb/DataObjects/Change_audit.php | 24 + MTrackWeb/DataObjects/Changes.php | 114 + MTrackWeb/DataObjects/Classifications.php | 22 + MTrackWeb/DataObjects/Components.php | 22 + .../DataObjects/Components_by_project.php | 21 + MTrackWeb/DataObjects/Effort.php | 24 + MTrackWeb/DataObjects/Group_membership.php | 22 + MTrackWeb/DataObjects/Groups.php | 21 + MTrackWeb/DataObjects/Keywords.php | 21 + MTrackWeb/DataObjects/Last_notification.php | 20 + MTrackWeb/DataObjects/MIGRATION_NOTES.txt | 44 + MTrackWeb/DataObjects/Milestones.php | 29 + MTrackWeb/DataObjects/Mtrack_schema.php | 20 + MTrackWeb/DataObjects/Priorities.php | 22 + MTrackWeb/DataObjects/Project_repo_link.php | 24 + MTrackWeb/DataObjects/Projects.php | 24 + MTrackWeb/DataObjects/Reports.php | 24 + MTrackWeb/DataObjects/Repos.php | 29 + MTrackWeb/DataObjects/Resolutions.php | 22 + MTrackWeb/DataObjects/Search_engine_state.php | 20 + MTrackWeb/DataObjects/Severities.php | 22 + MTrackWeb/DataObjects/Snippets.php | 25 + .../DataObjects/Ticket_changeset_hashes.php | 21 + MTrackWeb/DataObjects/Ticket_components.php | 21 + MTrackWeb/DataObjects/Ticket_keywords.php | 21 + MTrackWeb/DataObjects/Ticket_milestones.php | 21 + MTrackWeb/DataObjects/Tickets.php | 35 + MTrackWeb/DataObjects/Ticketstates.php | 22 + MTrackWeb/DataObjects/Useraliases.php | 21 + MTrackWeb/DataObjects/Userinfo.php | 25 + MTrackWeb/DataObjects/Watches.php | 25 + MTrackWeb/DataObjects/schema.sql | 240 ++ MTrackWeb/Events.php | 67 + MTrackWeb/File.php | 218 ++ MTrackWeb/Help.php | 35 + MTrackWeb/Hook/git.php | 65 + MTrackWeb/Hook/hg.php | 193 ++ MTrackWeb/Hook/svn.php | 51 + MTrackWeb/LinkHandler.php | 281 +++ MTrackWeb/Log.php | 144 ++ MTrackWeb/Milestone.php | 309 +++ MTrackWeb/OpenId.php | 215 ++ MTrackWeb/Preview.php | 14 + MTrackWeb/Query.php | 329 +++ MTrackWeb/Report.php | 100 + MTrackWeb/Reports.php | 69 + MTrackWeb/Roadmap.php | 95 + MTrackWeb/Setup/init.php | 527 ++++ MTrackWeb/Setup/make-authorized-keys.php | 73 + MTrackWeb/Setup/schema-tool.php | 145 ++ MTrackWeb/Snippet.php | 143 ++ MTrackWeb/Ticket.php | 544 ++++ MTrackWeb/Timeline.php | 180 ++ MTrackWeb/Tree.php | 355 +++ MTrackWeb/Watch.php | 95 + MTrackWeb/Wiki.php | 341 +++ MTrackWeb/templates/admin.html | 43 + MTrackWeb/templates/browse.html | 27 + MTrackWeb/templates/changeset.html | 70 + MTrackWeb/templates/file.html | 97 + MTrackWeb/templates/help.html | 16 + .../templates/images/js/mtrack.file.event.js | 65 + MTrackWeb/templates/images/js/mtrack.file.js | 54 + MTrackWeb/templates/images/js/mtrack.js | 316 +++ .../templates/images/js/mtrack.ticket.js | 363 +++ MTrackWeb/templates/images/js/mtrack.watch.js | 86 + .../templates/images/mtrack.changeset.css | 67 + MTrackWeb/templates/images/mtrack.log.css | 2 + MTrackWeb/templates/log.html | 62 + MTrackWeb/templates/master.html | 132 + MTrackWeb/templates/preview.html | 1 + MTrackWeb/templates/report.html | 49 + MTrackWeb/templates/reports.html | 23 + MTrackWeb/templates/ticket.html | 305 +++ MTrackWeb/templates/timeline.html | 88 + MTrackWeb/templates/tree.html | 244 ++ MTrackWeb/templates/watch.html | 30 + MTrackWeb/templates/wiki.html | 124 + Zend/Exception.php | 31 + Zend/Search/Exception.php | 37 + Zend/Search/Lucene.php | 1520 +++++++++++ Zend/Search/Lucene/Analysis/Analyzer.php | 177 ++ .../Lucene/Analysis/Analyzer/Common.php | 81 + .../Lucene/Analysis/Analyzer/Common/Text.php | 96 + .../Analyzer/Common/Text/CaseInsensitive.php | 47 + .../Analysis/Analyzer/Common/TextNum.php | 95 + .../Common/TextNum/CaseInsensitive.php | 47 + .../Lucene/Analysis/Analyzer/Common/Utf8.php | 126 + .../Analyzer/Common/Utf8/CaseInsensitive.php | 49 + .../Analysis/Analyzer/Common/Utf8Num.php | 126 + .../Common/Utf8Num/CaseInsensitive.php | 49 + Zend/Search/Lucene/Analysis/Token.php | 154 ++ Zend/Search/Lucene/Analysis/TokenFilter.php | 48 + .../Lucene/Analysis/TokenFilter/LowerCase.php | 58 + .../Analysis/TokenFilter/LowerCaseUtf8.php | 70 + .../Analysis/TokenFilter/ShortWords.php | 69 + .../Lucene/Analysis/TokenFilter/StopWords.php | 101 + Zend/Search/Lucene/Document.php | 131 + Zend/Search/Lucene/Document/Docx.php | 144 ++ Zend/Search/Lucene/Document/Exception.php | 37 + Zend/Search/Lucene/Document/Html.php | 457 ++++ Zend/Search/Lucene/Document/OpenXml.php | 132 + Zend/Search/Lucene/Document/Pptx.php | 193 ++ Zend/Search/Lucene/Document/Xlsx.php | 256 ++ Zend/Search/Lucene/Exception.php | 37 + Zend/Search/Lucene/FSM.php | 443 ++++ Zend/Search/Lucene/FSMAction.php | 66 + Zend/Search/Lucene/Field.php | 226 ++ Zend/Search/Lucene/Index/DictionaryLoader.php | 268 ++ Zend/Search/Lucene/Index/DocsFilter.php | 59 + Zend/Search/Lucene/Index/FieldInfo.php | 50 + Zend/Search/Lucene/Index/SegmentInfo.php | 2117 ++++++++++++++++ Zend/Search/Lucene/Index/SegmentMerger.php | 271 ++ Zend/Search/Lucene/Index/SegmentWriter.php | 627 +++++ .../Index/SegmentWriter/DocumentWriter.php | 214 ++ .../Index/SegmentWriter/StreamWriter.php | 94 + Zend/Search/Lucene/Index/Term.php | 144 ++ Zend/Search/Lucene/Index/TermInfo.php | 80 + .../Lucene/Index/TermsPriorityQueue.php | 49 + .../Lucene/Index/TermsStream/Interface.php | 66 + Zend/Search/Lucene/Index/Writer.php | 842 +++++++ Zend/Search/Lucene/Interface.php | 404 +++ Zend/Search/Lucene/LockManager.php | 236 ++ Zend/Search/Lucene/MultiSearcher.php | 963 +++++++ Zend/Search/Lucene/PriorityQueue.php | 171 ++ Zend/Search/Lucene/Proxy.php | 612 +++++ .../Search/BooleanExpressionRecognizer.php | 278 ++ .../Lucene/Search/Highlighter/Default.php | 94 + .../Lucene/Search/Highlighter/Interface.php | 53 + Zend/Search/Lucene/Search/Query.php | 234 ++ Zend/Search/Lucene/Search/Query/Boolean.php | 806 ++++++ Zend/Search/Lucene/Search/Query/Empty.php | 140 ++ Zend/Search/Lucene/Search/Query/Fuzzy.php | 488 ++++ .../Lucene/Search/Query/Insignificant.php | 141 ++ Zend/Search/Lucene/Search/Query/MultiTerm.php | 661 +++++ Zend/Search/Lucene/Search/Query/Phrase.php | 571 +++++ .../Lucene/Search/Query/Preprocessing.php | 134 + .../Search/Query/Preprocessing/Fuzzy.php | 287 +++ .../Search/Query/Preprocessing/Phrase.php | 274 ++ .../Search/Query/Preprocessing/Term.php | 335 +++ Zend/Search/Lucene/Search/Query/Range.php | 377 +++ Zend/Search/Lucene/Search/Query/Term.php | 227 ++ Zend/Search/Lucene/Search/Query/Wildcard.php | 351 +++ Zend/Search/Lucene/Search/QueryEntry.php | 79 + .../Lucene/Search/QueryEntry/Phrase.php | 120 + .../Lucene/Search/QueryEntry/Subquery.php | 80 + Zend/Search/Lucene/Search/QueryEntry/Term.php | 130 + Zend/Search/Lucene/Search/QueryHit.php | 109 + Zend/Search/Lucene/Search/QueryLexer.php | 510 ++++ Zend/Search/Lucene/Search/QueryParser.php | 636 +++++ .../Lucene/Search/QueryParserContext.php | 418 +++ .../Lucene/Search/QueryParserException.php | 41 + Zend/Search/Lucene/Search/QueryToken.php | 225 ++ Zend/Search/Lucene/Search/Similarity.php | 554 ++++ .../Lucene/Search/Similarity/Default.php | 110 + Zend/Search/Lucene/Search/Weight.php | 85 + Zend/Search/Lucene/Search/Weight/Boolean.php | 137 + Zend/Search/Lucene/Search/Weight/Empty.php | 57 + .../Search/Lucene/Search/Weight/MultiTerm.php | 138 + Zend/Search/Lucene/Search/Weight/Phrase.php | 108 + Zend/Search/Lucene/Search/Weight/Term.php | 125 + Zend/Search/Lucene/Storage/Directory.php | 136 + .../Lucene/Storage/Directory/Filesystem.php | 363 +++ Zend/Search/Lucene/Storage/File.php | 473 ++++ .../Search/Lucene/Storage/File/Filesystem.php | 220 ++ Zend/Search/Lucene/Storage/File/Memory.php | 601 +++++ .../Lucene/TermStreamsPriorityQueue.php | 176 ++ admin/auth.php | 294 +++ admin/component.php | 104 + admin/customfield.php | 199 ++ admin/deleterepo.php | 18 + admin/enum.php | 88 + admin/forkrepo.php | 53 + admin/group.php | 87 + admin/importcsv.php | 325 +++ admin/logs.php | 62 + admin/project.php | 214 ++ admin/repo.php | 282 +++ admin/user.php | 76 + admin/watch.php | 31 + css/hyperlight/plain.css | 65 + css/hyperlight/vibrant-ink.css | 50 + css/hyperlight/wezterm.css | 90 + css/hyperlight/zenburn.css | 64 + css/markitup/bold.png | Bin 0 -> 304 bytes css/markitup/code.png | Bin 0 -> 859 bytes css/markitup/h1.png | Bin 0 -> 276 bytes css/markitup/h2.png | Bin 0 -> 304 bytes css/markitup/h3.png | Bin 0 -> 306 bytes css/markitup/h4.png | Bin 0 -> 293 bytes css/markitup/h5.png | Bin 0 -> 304 bytes css/markitup/h6.png | Bin 0 -> 310 bytes css/markitup/handle.png | Bin 0 -> 258 bytes css/markitup/italic.png | Bin 0 -> 223 bytes css/markitup/link.png | Bin 0 -> 343 bytes css/markitup/list-bullet.png | Bin 0 -> 344 bytes css/markitup/list-numeric.png | Bin 0 -> 357 bytes css/markitup/markitup-simple.css | 128 + css/markitup/menu.png | Bin 0 -> 27151 bytes css/markitup/picture.png | Bin 0 -> 606 bytes css/markitup/preview.png | Bin 0 -> 537 bytes css/markitup/quotes.png | Bin 0 -> 743 bytes css/markitup/stroke.png | Bin 0 -> 269 bytes css/markitup/submenu.png | Bin 0 -> 240 bytes css/markitup/url.png | Bin 0 -> 957 bytes css/markitup/wiki.css | 61 + css/mtrack.css | 1393 ++++++++++ .../images/ui-bg_flat_0_aaaaaa_40x100.png | Bin 0 -> 180 bytes .../images/ui-bg_flat_75_ffffff_40x100.png | Bin 0 -> 178 bytes .../images/ui-bg_glass_55_fbf9ee_1x400.png | Bin 0 -> 120 bytes .../images/ui-bg_glass_65_ffffff_1x400.png | Bin 0 -> 105 bytes .../images/ui-bg_glass_75_dadada_1x400.png | Bin 0 -> 111 bytes .../images/ui-bg_glass_75_e6e6e6_1x400.png | Bin 0 -> 110 bytes .../images/ui-bg_glass_95_fef1ec_1x400.png | Bin 0 -> 119 bytes .../ui-bg_highlight-soft_75_cccccc_1x100.png | Bin 0 -> 101 bytes .../images/ui-icons_222222_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_2e83ff_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_454545_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_888888_256x240.png | Bin 0 -> 4369 bytes .../images/ui-icons_cd0a0a_256x240.png | Bin 0 -> 4369 bytes css/smoothness/jquery-ui-1.7.2.custom.css | 406 +++ css/ticket.css | 134 + help/ConfigIni | 181 ++ help/Install | 371 +++ help/Introduction | 16 + help/Links | 291 +++ help/Plugins | 13 + help/SSH | 127 + help/Searching | 264 ++ help/TicketQuery | 106 + help/TracReports | 249 ++ help/WikiFormatting | 403 +++ help/bin/Init | 136 + help/bin/Modify | 45 + help/plugin/AuthHTTP | 50 + help/plugin/CommitCheckNoEmpty | 15 + help/plugin/CommitCheckTimeRef | 26 + help/plugin/OpenID | 56 + help/plugin/Recaptcha | 23 + images/add.png | Bin 0 -> 983 bytes images/changeset.png | Bin 0 -> 294 bytes images/closedticket.png | Bin 0 -> 297 bytes images/default_avatar.png | Bin 0 -> 2999 bytes images/editedticket.png | Bin 0 -> 241 bytes images/feed-icon-16x16.png | Bin 0 -> 764 bytes images/file.png | Bin 0 -> 285 bytes images/filedeny.png | Bin 0 -> 285 bytes images/folder.png | Bin 0 -> 357 bytes images/folderdeny.png | Bin 0 -> 357 bytes images/gradient-footer.png | Bin 0 -> 122 bytes images/gradient-header.png | Bin 0 -> 118 bytes images/logo_openid.png | Bin 0 -> 1135 bytes images/milestone.png | Bin 0 -> 245 bytes images/newticket.png | Bin 0 -> 227 bytes images/parent.png | Bin 0 -> 228 bytes images/sort/asc.gif | Bin 0 -> 54 bytes images/sort/bg.gif | Bin 0 -> 64 bytes images/sort/desc.gif | Bin 0 -> 54 bytes images/treeview/file.gif | Bin 0 -> 110 bytes images/treeview/folder-closed.gif | Bin 0 -> 105 bytes images/treeview/folder.gif | Bin 0 -> 106 bytes images/treeview/minus.gif | Bin 0 -> 837 bytes images/treeview/plus.gif | Bin 0 -> 841 bytes images/treeview/treeview-black-line.gif | Bin 0 -> 1877 bytes images/treeview/treeview-black.gif | Bin 0 -> 1216 bytes images/treeview/treeview-default-line.gif | Bin 0 -> 1993 bytes images/treeview/treeview-default.gif | Bin 0 -> 1222 bytes images/treeview/treeview-famfamfam-line.gif | Bin 0 -> 807 bytes images/treeview/treeview-famfamfam.gif | Bin 0 -> 1280 bytes images/treeview/treeview-gray-line.gif | Bin 0 -> 1877 bytes images/treeview/treeview-gray.gif | Bin 0 -> 1230 bytes images/treeview/treeview-red-line.gif | Bin 0 -> 1877 bytes images/treeview/treeview-red.gif | Bin 0 -> 1230 bytes images/user.png | Bin 0 -> 810 bytes images/wiki.png | Bin 0 -> 233 bytes index.php | 30 + js/excanvas.pack.js | 1427 +++++++++++ js/jquery-1.4.2.min.js | 154 ++ js/jquery-ui-1.8.2.custom.min.js | 1012 ++++++++ js/jquery.MultiFile.pack.js | 11 + js/jquery.asmselect.js | 407 +++ js/jquery.cookie.js | 92 + js/jquery.flot.pack.js | 2119 ++++++++++++++++ js/jquery.markitup.js | 559 +++++ js/jquery.metadata.js | 122 + js/jquery.tablesorter.js | 852 +++++++ js/jquery.timeago.js | 140 ++ js/jquery.treeview.js | 251 ++ js/json2.js | 482 ++++ mtrack.css | 1403 +++++++++++ pear | 1 + search.php | 250 ++ templates/browse.html | 26 + templates/file.html | 99 + templates/ticket_edit.html | 309 +++ templates/tree.html | 244 ++ templates/watch.html | 30 + timeline.php | 253 ++ user.php | 331 +++ 452 files changed, 82012 insertions(+) create mode 100644 Auth/COPYING create mode 100644 Auth/OpenID.php create mode 100644 Auth/OpenID/AX.php create mode 100644 Auth/OpenID/Association.php create mode 100644 Auth/OpenID/BigMath.php create mode 100644 Auth/OpenID/Consumer.php create mode 100644 Auth/OpenID/CryptUtil.php create mode 100644 Auth/OpenID/DatabaseConnection.php create mode 100644 Auth/OpenID/DiffieHellman.php create mode 100644 Auth/OpenID/Discover.php create mode 100644 Auth/OpenID/DumbStore.php create mode 100644 Auth/OpenID/Extension.php create mode 100644 Auth/OpenID/FileStore.php create mode 100644 Auth/OpenID/HMAC.php create mode 100644 Auth/OpenID/Interface.php create mode 100644 Auth/OpenID/KVForm.php create mode 100644 Auth/OpenID/MemcachedStore.php create mode 100644 Auth/OpenID/Message.php create mode 100644 Auth/OpenID/MySQLStore.php create mode 100644 Auth/OpenID/Nonce.php create mode 100644 Auth/OpenID/PAPE.php create mode 100644 Auth/OpenID/Parse.php create mode 100644 Auth/OpenID/PostgreSQLStore.php create mode 100644 Auth/OpenID/SQLStore.php create mode 100644 Auth/OpenID/SQLiteStore.php create mode 100644 Auth/OpenID/SReg.php create mode 100644 Auth/OpenID/Server.php create mode 100644 Auth/OpenID/ServerRequest.php create mode 100644 Auth/OpenID/TrustRoot.php create mode 100644 Auth/OpenID/URINorm.php create mode 100644 Auth/Yadis/HTTPFetcher.php create mode 100644 Auth/Yadis/Manager.php create mode 100644 Auth/Yadis/Misc.php create mode 100644 Auth/Yadis/ParanoidHTTPFetcher.php create mode 100644 Auth/Yadis/ParseHTML.php create mode 100644 Auth/Yadis/PlainHTTPFetcher.php create mode 100644 Auth/Yadis/XML.php create mode 100644 Auth/Yadis/XRDS.php create mode 100644 Auth/Yadis/XRI.php create mode 100644 Auth/Yadis/XRIRes.php create mode 100644 Auth/Yadis/Yadis.php create mode 100644 MTrack/ACL.php create mode 100644 MTrack/Attachment.php create mode 100644 MTrack/Auth.php create mode 100644 MTrack/Captcha.php create mode 100644 MTrack/Captcha/Recaptcha.php create mode 100644 MTrack/Changeset.php create mode 100644 MTrack/Classification.php create mode 100644 MTrack/CommitCheck/BlankLines.php create mode 100644 MTrack/CommitCheck/NoEmptyLogMessage.php create mode 100644 MTrack/CommitCheck/PhpLint.php create mode 100644 MTrack/CommitCheck/RequiresTimeReference.php create mode 100644 MTrack/CommitCheck/SingleIssue.php create mode 100644 MTrack/CommitCheck/UnixLineBreak.php create mode 100644 MTrack/CommitCheck/Wiki.php create mode 100644 MTrack/CommitChecker.php create mode 100644 MTrack/CommitHookChangeEvent.php create mode 100644 MTrack/Component.php create mode 100644 MTrack/Config.php create mode 100644 MTrack/DB.php create mode 100644 MTrack/DBSchema.php create mode 100644 MTrack/DBSchema/Generic.php create mode 100644 MTrack/DBSchema/SQLite.php create mode 100644 MTrack/DBSchema/Table.php create mode 100644 MTrack/DBSchema/mysql.php create mode 100644 MTrack/DBSchema/pgsql.php create mode 100644 MTrack/DataObjects/Event.php create mode 100644 MTrack/DataObjects/Userinfo.php create mode 100644 MTrack/Enumeration.php create mode 100644 MTrack/Exception/Authorization.php create mode 100644 MTrack/Exception/DB.php create mode 100644 MTrack/Exception/Veto.php create mode 100644 MTrack/Interface/Auth.php create mode 100644 MTrack/Interface/Captcha.php create mode 100644 MTrack/Interface/CommitHookBridge.php create mode 100644 MTrack/Interface/CommitHookBridge2.php create mode 100644 MTrack/Interface/CommitListener.php create mode 100644 MTrack/Interface/DBExtension.php create mode 100644 MTrack/Interface/DBSchema_Driver.php create mode 100644 MTrack/Interface/IssueListener.php create mode 100644 MTrack/Interface/SearchEngine.php create mode 100644 MTrack/Interface/WikiLinkHandler.php create mode 100644 MTrack/Issue.php create mode 100644 MTrack/Keyword.php create mode 100644 MTrack/Milestone.php create mode 100644 MTrack/Priority.php create mode 100644 MTrack/Project.php create mode 100644 MTrack/Repo.php create mode 100644 MTrack/Report.php create mode 100644 MTrack/Resolution.php create mode 100644 MTrack/SCM.php create mode 100644 MTrack/SCM/Git/CommitHookBridge.php create mode 100644 MTrack/SCM/Git/Event.php create mode 100644 MTrack/SCM/Git/File.php create mode 100644 MTrack/SCM/Git/Repo.php create mode 100644 MTrack/SCM/Git/WorkingCopy.php create mode 100644 MTrack/SCM/Hg.php create mode 100644 MTrack/SCM/Svn.php create mode 100644 MTrack/SCM/Svn/CommitHookBridge.php create mode 100644 MTrack/SCMAnnotation.php create mode 100644 MTrack/SCMEvent.php create mode 100644 MTrack/SCMFile.php create mode 100644 MTrack/SCMFileEvent.php create mode 100644 MTrack/SCMWorkingCopy.php create mode 100644 MTrack/SearchDB.php create mode 100644 MTrack/SearchResult.php create mode 100644 MTrack/Severity.php create mode 100644 MTrack/Snippet.php create mode 100644 MTrack/SyntaxHighlight.php create mode 100644 MTrack/TicketState.php create mode 100644 MTrack/Ticket_CustomField.php create mode 100644 MTrack/Ticket_CustomFields.php create mode 100644 MTrack/UUID.php create mode 100644 MTrack/Watch.php create mode 100644 MTrack/Wiki.php create mode 100644 MTrack/Wiki/HTMLFormatter.php create mode 100644 MTrack/Wiki/Item.php create mode 100644 MTrack/Wiki/OneLinerFormatter.php create mode 100644 MTrack/Wiki/Parser.php create mode 100644 MTrack/auth/http.php create mode 100644 MTrack/auth/openid.php create mode 100644 MTrack/cache.php create mode 100644 MTrack/hyperlight/cpp.php create mode 100644 MTrack/hyperlight/csharp.php create mode 100644 MTrack/hyperlight/css.php create mode 100644 MTrack/hyperlight/hyperlight.php create mode 100644 MTrack/hyperlight/iphp.php create mode 100644 MTrack/hyperlight/javascript.php create mode 100644 MTrack/hyperlight/perl.php create mode 100644 MTrack/hyperlight/php.php create mode 100644 MTrack/hyperlight/preg_helper.php create mode 100644 MTrack/hyperlight/python.php create mode 100644 MTrack/hyperlight/shell.php create mode 100644 MTrack/hyperlight/vb.php create mode 100644 MTrack/hyperlight/vibrant-ink.css create mode 100644 MTrack/hyperlight/wezterm.css create mode 100644 MTrack/hyperlight/wiki.php create mode 100644 MTrack/hyperlight/xml.php create mode 100644 MTrack/hyperlight/zenburn.css create mode 100644 MTrack/search/lucene.php create mode 100644 MTrack/search/solr.php create mode 100644 MTrack/web.php create mode 100644 MTrackWeb.php create mode 100644 MTrackWeb/Admin.php create mode 100644 MTrackWeb/Attachment.php create mode 100644 MTrackWeb/Avatar.php create mode 100644 MTrackWeb/Browse.php create mode 100644 MTrackWeb/Changeset.php create mode 100644 MTrackWeb/CreateSchema.php create mode 100644 MTrackWeb/Cron/send-notifications.php create mode 100644 MTrackWeb/Cron/update-search-index.php create mode 100755 MTrackWeb/DataObjects/Acl.php create mode 100755 MTrackWeb/DataObjects/Attachments.php create mode 100755 MTrackWeb/DataObjects/Change_audit.php create mode 100755 MTrackWeb/DataObjects/Changes.php create mode 100755 MTrackWeb/DataObjects/Classifications.php create mode 100755 MTrackWeb/DataObjects/Components.php create mode 100755 MTrackWeb/DataObjects/Components_by_project.php create mode 100755 MTrackWeb/DataObjects/Effort.php create mode 100755 MTrackWeb/DataObjects/Group_membership.php create mode 100755 MTrackWeb/DataObjects/Groups.php create mode 100755 MTrackWeb/DataObjects/Keywords.php create mode 100755 MTrackWeb/DataObjects/Last_notification.php create mode 100644 MTrackWeb/DataObjects/MIGRATION_NOTES.txt create mode 100755 MTrackWeb/DataObjects/Milestones.php create mode 100755 MTrackWeb/DataObjects/Mtrack_schema.php create mode 100755 MTrackWeb/DataObjects/Priorities.php create mode 100755 MTrackWeb/DataObjects/Project_repo_link.php create mode 100755 MTrackWeb/DataObjects/Projects.php create mode 100755 MTrackWeb/DataObjects/Reports.php create mode 100755 MTrackWeb/DataObjects/Repos.php create mode 100755 MTrackWeb/DataObjects/Resolutions.php create mode 100755 MTrackWeb/DataObjects/Search_engine_state.php create mode 100755 MTrackWeb/DataObjects/Severities.php create mode 100755 MTrackWeb/DataObjects/Snippets.php create mode 100755 MTrackWeb/DataObjects/Ticket_changeset_hashes.php create mode 100755 MTrackWeb/DataObjects/Ticket_components.php create mode 100755 MTrackWeb/DataObjects/Ticket_keywords.php create mode 100755 MTrackWeb/DataObjects/Ticket_milestones.php create mode 100755 MTrackWeb/DataObjects/Tickets.php create mode 100755 MTrackWeb/DataObjects/Ticketstates.php create mode 100755 MTrackWeb/DataObjects/Useraliases.php create mode 100755 MTrackWeb/DataObjects/Userinfo.php create mode 100755 MTrackWeb/DataObjects/Watches.php create mode 100644 MTrackWeb/DataObjects/schema.sql create mode 100644 MTrackWeb/Events.php create mode 100644 MTrackWeb/File.php create mode 100644 MTrackWeb/Help.php create mode 100755 MTrackWeb/Hook/git.php create mode 100755 MTrackWeb/Hook/hg.php create mode 100755 MTrackWeb/Hook/svn.php create mode 100644 MTrackWeb/LinkHandler.php create mode 100644 MTrackWeb/Log.php create mode 100644 MTrackWeb/Milestone.php create mode 100644 MTrackWeb/OpenId.php create mode 100644 MTrackWeb/Preview.php create mode 100644 MTrackWeb/Query.php create mode 100644 MTrackWeb/Report.php create mode 100644 MTrackWeb/Reports.php create mode 100644 MTrackWeb/Roadmap.php create mode 100644 MTrackWeb/Setup/init.php create mode 100644 MTrackWeb/Setup/make-authorized-keys.php create mode 100644 MTrackWeb/Setup/schema-tool.php create mode 100644 MTrackWeb/Snippet.php create mode 100644 MTrackWeb/Ticket.php create mode 100644 MTrackWeb/Timeline.php create mode 100644 MTrackWeb/Tree.php create mode 100644 MTrackWeb/Watch.php create mode 100644 MTrackWeb/Wiki.php create mode 100644 MTrackWeb/templates/admin.html create mode 100644 MTrackWeb/templates/browse.html create mode 100644 MTrackWeb/templates/changeset.html create mode 100644 MTrackWeb/templates/file.html create mode 100644 MTrackWeb/templates/help.html create mode 100644 MTrackWeb/templates/images/js/mtrack.file.event.js create mode 100644 MTrackWeb/templates/images/js/mtrack.file.js create mode 100644 MTrackWeb/templates/images/js/mtrack.js create mode 100644 MTrackWeb/templates/images/js/mtrack.ticket.js create mode 100644 MTrackWeb/templates/images/js/mtrack.watch.js create mode 100644 MTrackWeb/templates/images/mtrack.changeset.css create mode 100644 MTrackWeb/templates/images/mtrack.log.css create mode 100644 MTrackWeb/templates/log.html create mode 100644 MTrackWeb/templates/master.html create mode 100644 MTrackWeb/templates/preview.html create mode 100644 MTrackWeb/templates/report.html create mode 100644 MTrackWeb/templates/reports.html create mode 100644 MTrackWeb/templates/ticket.html create mode 100644 MTrackWeb/templates/timeline.html create mode 100644 MTrackWeb/templates/tree.html create mode 100644 MTrackWeb/templates/watch.html create mode 100644 MTrackWeb/templates/wiki.html create mode 100644 Zend/Exception.php create mode 100644 Zend/Search/Exception.php create mode 100644 Zend/Search/Lucene.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php create mode 100644 Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php create mode 100644 Zend/Search/Lucene/Analysis/Token.php create mode 100644 Zend/Search/Lucene/Analysis/TokenFilter.php create mode 100644 Zend/Search/Lucene/Analysis/TokenFilter/LowerCase.php create mode 100644 Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php create mode 100644 Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php create mode 100644 Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php create mode 100644 Zend/Search/Lucene/Document.php create mode 100644 Zend/Search/Lucene/Document/Docx.php create mode 100644 Zend/Search/Lucene/Document/Exception.php create mode 100644 Zend/Search/Lucene/Document/Html.php create mode 100644 Zend/Search/Lucene/Document/OpenXml.php create mode 100644 Zend/Search/Lucene/Document/Pptx.php create mode 100644 Zend/Search/Lucene/Document/Xlsx.php create mode 100644 Zend/Search/Lucene/Exception.php create mode 100644 Zend/Search/Lucene/FSM.php create mode 100644 Zend/Search/Lucene/FSMAction.php create mode 100644 Zend/Search/Lucene/Field.php create mode 100644 Zend/Search/Lucene/Index/DictionaryLoader.php create mode 100644 Zend/Search/Lucene/Index/DocsFilter.php create mode 100644 Zend/Search/Lucene/Index/FieldInfo.php create mode 100644 Zend/Search/Lucene/Index/SegmentInfo.php create mode 100644 Zend/Search/Lucene/Index/SegmentMerger.php create mode 100644 Zend/Search/Lucene/Index/SegmentWriter.php create mode 100644 Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php create mode 100644 Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php create mode 100644 Zend/Search/Lucene/Index/Term.php create mode 100644 Zend/Search/Lucene/Index/TermInfo.php create mode 100644 Zend/Search/Lucene/Index/TermsPriorityQueue.php create mode 100644 Zend/Search/Lucene/Index/TermsStream/Interface.php create mode 100644 Zend/Search/Lucene/Index/Writer.php create mode 100644 Zend/Search/Lucene/Interface.php create mode 100644 Zend/Search/Lucene/LockManager.php create mode 100644 Zend/Search/Lucene/MultiSearcher.php create mode 100644 Zend/Search/Lucene/PriorityQueue.php create mode 100644 Zend/Search/Lucene/Proxy.php create mode 100644 Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php create mode 100644 Zend/Search/Lucene/Search/Highlighter/Default.php create mode 100644 Zend/Search/Lucene/Search/Highlighter/Interface.php create mode 100644 Zend/Search/Lucene/Search/Query.php create mode 100644 Zend/Search/Lucene/Search/Query/Boolean.php create mode 100644 Zend/Search/Lucene/Search/Query/Empty.php create mode 100644 Zend/Search/Lucene/Search/Query/Fuzzy.php create mode 100644 Zend/Search/Lucene/Search/Query/Insignificant.php create mode 100644 Zend/Search/Lucene/Search/Query/MultiTerm.php create mode 100644 Zend/Search/Lucene/Search/Query/Phrase.php create mode 100644 Zend/Search/Lucene/Search/Query/Preprocessing.php create mode 100644 Zend/Search/Lucene/Search/Query/Preprocessing/Fuzzy.php create mode 100644 Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php create mode 100644 Zend/Search/Lucene/Search/Query/Preprocessing/Term.php create mode 100644 Zend/Search/Lucene/Search/Query/Range.php create mode 100644 Zend/Search/Lucene/Search/Query/Term.php create mode 100644 Zend/Search/Lucene/Search/Query/Wildcard.php create mode 100644 Zend/Search/Lucene/Search/QueryEntry.php create mode 100644 Zend/Search/Lucene/Search/QueryEntry/Phrase.php create mode 100644 Zend/Search/Lucene/Search/QueryEntry/Subquery.php create mode 100644 Zend/Search/Lucene/Search/QueryEntry/Term.php create mode 100644 Zend/Search/Lucene/Search/QueryHit.php create mode 100644 Zend/Search/Lucene/Search/QueryLexer.php create mode 100644 Zend/Search/Lucene/Search/QueryParser.php create mode 100644 Zend/Search/Lucene/Search/QueryParserContext.php create mode 100644 Zend/Search/Lucene/Search/QueryParserException.php create mode 100644 Zend/Search/Lucene/Search/QueryToken.php create mode 100644 Zend/Search/Lucene/Search/Similarity.php create mode 100644 Zend/Search/Lucene/Search/Similarity/Default.php create mode 100644 Zend/Search/Lucene/Search/Weight.php create mode 100644 Zend/Search/Lucene/Search/Weight/Boolean.php create mode 100644 Zend/Search/Lucene/Search/Weight/Empty.php create mode 100644 Zend/Search/Lucene/Search/Weight/MultiTerm.php create mode 100644 Zend/Search/Lucene/Search/Weight/Phrase.php create mode 100644 Zend/Search/Lucene/Search/Weight/Term.php create mode 100644 Zend/Search/Lucene/Storage/Directory.php create mode 100644 Zend/Search/Lucene/Storage/Directory/Filesystem.php create mode 100644 Zend/Search/Lucene/Storage/File.php create mode 100644 Zend/Search/Lucene/Storage/File/Filesystem.php create mode 100644 Zend/Search/Lucene/Storage/File/Memory.php create mode 100644 Zend/Search/Lucene/TermStreamsPriorityQueue.php create mode 100644 admin/auth.php create mode 100644 admin/component.php create mode 100644 admin/customfield.php create mode 100644 admin/deleterepo.php create mode 100644 admin/enum.php create mode 100644 admin/forkrepo.php create mode 100644 admin/group.php create mode 100644 admin/importcsv.php create mode 100644 admin/logs.php create mode 100644 admin/project.php create mode 100644 admin/repo.php create mode 100644 admin/user.php create mode 100644 admin/watch.php create mode 100644 css/hyperlight/plain.css create mode 100644 css/hyperlight/vibrant-ink.css create mode 100644 css/hyperlight/wezterm.css create mode 100644 css/hyperlight/zenburn.css create mode 100755 css/markitup/bold.png create mode 100755 css/markitup/code.png create mode 100755 css/markitup/h1.png create mode 100755 css/markitup/h2.png create mode 100755 css/markitup/h3.png create mode 100755 css/markitup/h4.png create mode 100755 css/markitup/h5.png create mode 100755 css/markitup/h6.png create mode 100755 css/markitup/handle.png create mode 100755 css/markitup/italic.png create mode 100755 css/markitup/link.png create mode 100755 css/markitup/list-bullet.png create mode 100755 css/markitup/list-numeric.png create mode 100755 css/markitup/markitup-simple.css create mode 100755 css/markitup/menu.png create mode 100755 css/markitup/picture.png create mode 100755 css/markitup/preview.png create mode 100755 css/markitup/quotes.png create mode 100755 css/markitup/stroke.png create mode 100755 css/markitup/submenu.png create mode 100755 css/markitup/url.png create mode 100644 css/markitup/wiki.css create mode 100644 css/mtrack.css create mode 100755 css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png create mode 100755 css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png create mode 100755 css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png create mode 100755 css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png create mode 100755 css/smoothness/images/ui-bg_glass_75_dadada_1x400.png create mode 100755 css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png create mode 100755 css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png create mode 100755 css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png create mode 100755 css/smoothness/images/ui-icons_222222_256x240.png create mode 100755 css/smoothness/images/ui-icons_2e83ff_256x240.png create mode 100755 css/smoothness/images/ui-icons_454545_256x240.png create mode 100755 css/smoothness/images/ui-icons_888888_256x240.png create mode 100755 css/smoothness/images/ui-icons_cd0a0a_256x240.png create mode 100755 css/smoothness/jquery-ui-1.7.2.custom.css create mode 100644 css/ticket.css create mode 100644 help/ConfigIni create mode 100644 help/Install create mode 100644 help/Introduction create mode 100644 help/Links create mode 100644 help/Plugins create mode 100644 help/SSH create mode 100644 help/Searching create mode 100644 help/TicketQuery create mode 100644 help/TracReports create mode 100644 help/WikiFormatting create mode 100644 help/bin/Init create mode 100644 help/bin/Modify create mode 100644 help/plugin/AuthHTTP create mode 100644 help/plugin/CommitCheckNoEmpty create mode 100644 help/plugin/CommitCheckTimeRef create mode 100644 help/plugin/OpenID create mode 100644 help/plugin/Recaptcha create mode 100644 images/add.png create mode 100644 images/changeset.png create mode 100644 images/closedticket.png create mode 100755 images/default_avatar.png create mode 100644 images/editedticket.png create mode 100644 images/feed-icon-16x16.png create mode 100644 images/file.png create mode 100644 images/filedeny.png create mode 100644 images/folder.png create mode 100644 images/folderdeny.png create mode 100644 images/gradient-footer.png create mode 100644 images/gradient-header.png create mode 100644 images/logo_openid.png create mode 100644 images/milestone.png create mode 100644 images/newticket.png create mode 100644 images/parent.png create mode 100755 images/sort/asc.gif create mode 100755 images/sort/bg.gif create mode 100755 images/sort/desc.gif create mode 100644 images/treeview/file.gif create mode 100644 images/treeview/folder-closed.gif create mode 100644 images/treeview/folder.gif create mode 100644 images/treeview/minus.gif create mode 100644 images/treeview/plus.gif create mode 100644 images/treeview/treeview-black-line.gif create mode 100644 images/treeview/treeview-black.gif create mode 100644 images/treeview/treeview-default-line.gif create mode 100644 images/treeview/treeview-default.gif create mode 100644 images/treeview/treeview-famfamfam-line.gif create mode 100644 images/treeview/treeview-famfamfam.gif create mode 100644 images/treeview/treeview-gray-line.gif create mode 100644 images/treeview/treeview-gray.gif create mode 100644 images/treeview/treeview-red-line.gif create mode 100644 images/treeview/treeview-red.gif create mode 100644 images/user.png create mode 100644 images/wiki.png create mode 100644 index.php create mode 100644 js/excanvas.pack.js create mode 100644 js/jquery-1.4.2.min.js create mode 100755 js/jquery-ui-1.8.2.custom.min.js create mode 100755 js/jquery.MultiFile.pack.js create mode 100644 js/jquery.asmselect.js create mode 100644 js/jquery.cookie.js create mode 100644 js/jquery.flot.pack.js create mode 100755 js/jquery.markitup.js create mode 100755 js/jquery.metadata.js create mode 100644 js/jquery.tablesorter.js create mode 100644 js/jquery.timeago.js create mode 100644 js/jquery.treeview.js create mode 100644 js/json2.js create mode 100644 mtrack.css create mode 120000 pear create mode 100644 search.php create mode 100644 templates/browse.html create mode 100644 templates/file.html create mode 100644 templates/ticket_edit.html create mode 100644 templates/tree.html create mode 100644 templates/watch.html create mode 100644 timeline.php create mode 100644 user.php diff --git a/Auth/COPYING b/Auth/COPYING new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/Auth/COPYING @@ -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/Auth/OpenID.php b/Auth/OpenID.php new file mode 100644 index 00000000..6556b5b0 --- /dev/null +++ b/Auth/OpenID.php @@ -0,0 +1,552 @@ + + * @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("". + "". + $title . + "". + "". + $form . + "". + "". + ""); + } +} +?> diff --git a/Auth/OpenID/AX.php b/Auth/OpenID/AX.php new file mode 100644 index 00000000..4a617ae3 --- /dev/null +++ b/Auth/OpenID/AX.php @@ -0,0 +1,1023 @@ +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/Auth/OpenID/Association.php b/Auth/OpenID/Association.php new file mode 100644 index 00000000..37ce0cbf --- /dev/null +++ b/Auth/OpenID/Association.php @@ -0,0 +1,613 @@ + + * @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/Auth/OpenID/BigMath.php b/Auth/OpenID/BigMath.php new file mode 100644 index 00000000..6d99c4cb --- /dev/null +++ b/Auth/OpenID/BigMath.php @@ -0,0 +1,471 @@ + + * @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/Auth/OpenID/Consumer.php b/Auth/OpenID/Consumer.php new file mode 100644 index 00000000..cc0ab247 --- /dev/null +++ b/Auth/OpenID/Consumer.php @@ -0,0 +1,2230 @@ + + * @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', + ''); + + $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', + ''); + + 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', ''); + $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/Auth/OpenID/CryptUtil.php b/Auth/OpenID/CryptUtil.php new file mode 100644 index 00000000..aacc3cd3 --- /dev/null +++ b/Auth/OpenID/CryptUtil.php @@ -0,0 +1,109 @@ + + * @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 + * Auth_OpenID_RAND_SOURCE as null, 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/Auth/OpenID/DatabaseConnection.php b/Auth/OpenID/DatabaseConnection.php new file mode 100644 index 00000000..9db6e0eb --- /dev/null +++ b/Auth/OpenID/DatabaseConnection.php @@ -0,0 +1,131 @@ + + * @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/Auth/OpenID/DiffieHellman.php b/Auth/OpenID/DiffieHellman.php new file mode 100644 index 00000000..f4ded7eb --- /dev/null +++ b/Auth/OpenID/DiffieHellman.php @@ -0,0 +1,113 @@ + + * @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/Auth/OpenID/Discover.php b/Auth/OpenID/Discover.php new file mode 100644 index 00000000..62aeb1d2 --- /dev/null +++ b/Auth/OpenID/Discover.php @@ -0,0 +1,548 @@ +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 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 + // + $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 + $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/Auth/OpenID/DumbStore.php b/Auth/OpenID/DumbStore.php new file mode 100644 index 00000000..22fd2d36 --- /dev/null +++ b/Auth/OpenID/DumbStore.php @@ -0,0 +1,100 @@ + + * @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/Auth/OpenID/Extension.php b/Auth/OpenID/Extension.php new file mode 100644 index 00000000..f362a4b3 --- /dev/null +++ b/Auth/OpenID/Extension.php @@ -0,0 +1,62 @@ +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/Auth/OpenID/FileStore.php b/Auth/OpenID/FileStore.php new file mode 100644 index 00000000..29d8d20e --- /dev/null +++ b/Auth/OpenID/FileStore.php @@ -0,0 +1,618 @@ + + * @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/Auth/OpenID/HMAC.php b/Auth/OpenID/HMAC.php new file mode 100644 index 00000000..ec42db8d --- /dev/null +++ b/Auth/OpenID/HMAC.php @@ -0,0 +1,99 @@ + + * @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/Auth/OpenID/Interface.php b/Auth/OpenID/Interface.php new file mode 100644 index 00000000..f4c6062f --- /dev/null +++ b/Auth/OpenID/Interface.php @@ -0,0 +1,197 @@ + + * @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. + */ +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/Auth/OpenID/KVForm.php b/Auth/OpenID/KVForm.php new file mode 100644 index 00000000..fb342a00 --- /dev/null +++ b/Auth/OpenID/KVForm.php @@ -0,0 +1,112 @@ + + * @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/Auth/OpenID/MemcachedStore.php b/Auth/OpenID/MemcachedStore.php new file mode 100644 index 00000000..d357c6b1 --- /dev/null +++ b/Auth/OpenID/MemcachedStore.php @@ -0,0 +1,208 @@ + + * @copyright 2008 JanRain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + * Contributed by Open Web Technologies + */ + +/** + * 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/Auth/OpenID/Message.php b/Auth/OpenID/Message.php new file mode 100644 index 00000000..5ab115a8 --- /dev/null +++ b/Auth/OpenID/Message.php @@ -0,0 +1,920 @@ +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 = "
$attr) { + $form .= sprintf(" %s=\"%s\"", $name, $attr); + } + } + + $form .= ">\n"; + + foreach ($this->toPostArgs() as $name => $value) { + $form .= sprintf( + "\n", + $name, $value); + } + + $form .= sprintf("\n", + $submit_text); + + $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/Auth/OpenID/MySQLStore.php b/Auth/OpenID/MySQLStore.php new file mode 100644 index 00000000..eb08af01 --- /dev/null +++ b/Auth/OpenID/MySQLStore.php @@ -0,0 +1,78 @@ +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/Auth/OpenID/Nonce.php b/Auth/OpenID/Nonce.php new file mode 100644 index 00000000..effecac3 --- /dev/null +++ b/Auth/OpenID/Nonce.php @@ -0,0 +1,109 @@ + \ No newline at end of file diff --git a/Auth/OpenID/PAPE.php b/Auth/OpenID/PAPE.php new file mode 100644 index 00000000..62cba8a9 --- /dev/null +++ b/Auth/OpenID/PAPE.php @@ -0,0 +1,301 @@ +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/Auth/OpenID/Parse.php b/Auth/OpenID/Parse.php new file mode 100644 index 00000000..546f34f6 --- /dev/null +++ b/Auth/OpenID/Parse.php @@ -0,0 +1,352 @@ + 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 tag + * + * - There must be an open tag inside of the tag + * + * - Only s that are found inside of the 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 &, + * <, >, and ". + * + * The parser ignores SGML comments and . Both kinds + * of quoting are allowed for attributes. + * + * The parser deals with invalid markup in these ways: + * + * - Tag names are not case-sensitive + * + * - The tag is accepted even when it is not at the top level + * + * - The tag is accepted even when it is not a direct child of + * the tag, but a tag must be an ancestor of the + * tag + * + * - tags are accepted even when they are not direct children + * of the tag, but a tag must be an ancestor of the + * tag + * + * - If there is no closing tag for an open or tag, the + * remainder of the document is viewed as being inside of the + * tag. If there is no closing tag for a tag, the link tag is + * treated as a short tag. Exceptions to this rule are that + * closes and or closes + * + * - Attributes of the 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. will + * ignore pumpkin) + * + * - If there are more than one or tag, the parser only + * looks inside of the first one. + * + * - The contents of + +HTML; + + } +} + diff --git a/MTrack/Attachment.php b/MTrack/Attachment.php new file mode 100644 index 00000000..244e4bf5 --- /dev/null +++ b/MTrack/Attachment.php @@ -0,0 +1,217 @@ +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 = <<Select the checkbox to delete an attachment + + + + + + + +HTML; + + foreach ($atts as $row) { + $url = "{$ABSWEB}attachment.php/$object/$row[cid]/$row[filename]"; + $html .= << + + + + \n"; + } + $html .= "
 AttachmentSizeAdded
$row[filename]$row[size] +HTML; + $html .= mtrack_username($row['who'], array( + 'no_image' => true + )) . + " " . mtrack_date($row['changedate']) . "

"; + 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 = "
Attachments
    "; + foreach ($atts as $row) { + $url = "{$ABSWEB}attachment.php/$object/$row[cid]/$row[filename]"; + $html .= "
  • ". + "$row[filename] ($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 .= "
    "; + } + + $html .= "
  • \n"; + } + $html .= "
"; + return $html; + } +} diff --git a/MTrack/Auth.php b/MTrack/Auth.php new file mode 100644 index 00000000..3628cd1a --- /dev/null +++ b/MTrack/Auth.php @@ -0,0 +1,269 @@ +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 "

Not authorized

"; + 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/MTrack/Captcha.php b/MTrack/Captcha.php new file mode 100644 index 00000000..6017caf5 --- /dev/null +++ b/MTrack/Captcha.php @@ -0,0 +1,29 @@ +emit($form); + } + return ''; + } + + static function check($form) + { + if (self::$impl !== null) { + return self::$impl->check($form); + } + return true; + } +} diff --git a/MTrack/Captcha/Recaptcha.php b/MTrack/Captcha/Recaptcha.php new file mode 100644 index 00000000..fedff743 --- /dev/null +++ b/MTrack/Captcha/Recaptcha.php @@ -0,0 +1,81 @@ +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; + } + + 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/MTrack/Changeset.php b/MTrack/Changeset.php new file mode 100644 index 00000000..415ca5da --- /dev/null +++ b/MTrack/Changeset.php @@ -0,0 +1,110 @@ +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/MTrack/Classification.php b/MTrack/Classification.php new file mode 100644 index 00000000..c9f59367 --- /dev/null +++ b/MTrack/Classification.php @@ -0,0 +1,14 @@ +bridge, 'getDiffStream')) { // kludge - we should use interface.... + return true; + } + $fp = $checker->bridge->getDiffStream(); + $diff = stream_get_contents($fp); + $lines = explode("\n",$contents); + $seq = 0; + $total = 0; + + // probably a CRLF fix.... + if (count($lines) > 100) { + return; + } + + foreach($lines as $l) { + $ll = trim($l); + if ($l != '+') { + $seq =0; + continue; + } + // got blannk line + $seq++; + + if ($seq > 2) { + return "You are adding more than 2 blank lines - please remove the new blank lines you added and try again."; + } + } + + + + return true; + } + + function postCommit($msg, $files, $actions) { + return true; + } + +} diff --git a/MTrack/CommitCheck/NoEmptyLogMessage.php b/MTrack/CommitCheck/NoEmptyLogMessage.php new file mode 100644 index 00000000..1b1629a2 --- /dev/null +++ b/MTrack/CommitCheck/NoEmptyLogMessage.php @@ -0,0 +1,18 @@ + 30) { + // to many files.. we can not check that amount without causing serious delays in commits + return true; + } + foreach ($files as $filename) { + $pi = pathinfo($filename); + + if ( $pi['extension'] == 'php') { + $fp = $checker->bridge->getFileStream($filename); + $res = $this->checkPHP($filename, $fp); + if ($res !== true) { + $ret[] = $res; + } + $fp = null; // remove stream. + } + + } + + return $ret ? implode("\n", $ret) : true; + } + + function postCommit($msg, $files, $actions) + { + return true; + } + 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) { + return "$filename: $output"; + } + return true; + } +} \ No newline at end of file diff --git a/MTrack/CommitCheck/RequiresTimeReference.php b/MTrack/CommitCheck/RequiresTimeReference.php new file mode 100644 index 00000000..d8d4e771 --- /dev/null +++ b/MTrack/CommitCheck/RequiresTimeReference.php @@ -0,0 +1,22 @@ + 30) { + // to many files.. we can not check that amount without causing serious delays in commits + return true; + } + + foreach ($files as $filename) { + $pi = pathinfo($filename); + switch($pi['extension']) { + case 'php': + case 'html': + $fp = $checker->bridge->getFileStream($filename); + + $res = $this->checkLineBreaks($filename, $fp); + if ($res !== true) { + $ret[] = $res; + } + $fp = null; // remove stream. + } + } + + return $ret ? implode("\n", $ret) : true; + } + + function postCommit($msg, $files, $actions) { + return true; + } + function checkLineBreaks($filename, $fp) + { + $pipes = null; + $contents = stream_get_contents($fp); + if (preg_match("/\r+/", $contents)) { + return "Use Unix line endings only in $filename"; + } + + return true; + } +} \ No newline at end of file diff --git a/MTrack/CommitCheck/Wiki.php b/MTrack/CommitCheck/Wiki.php new file mode 100644 index 00000000..80c6f129 --- /dev/null +++ b/MTrack/CommitCheck/Wiki.php @@ -0,0 +1,41 @@ +commit(); + } + } + return true; + } + + + +}; \ No newline at end of file diff --git a/MTrack/CommitChecker.php b/MTrack/CommitChecker.php new file mode 100644 index 00000000..c7416f6f --- /dev/null +++ b/MTrack/CommitChecker.php @@ -0,0 +1,341 @@ + 'checkPHP', + ); + static $listeners = array(); + + static function addCheck($name) + { + require_once "MTrack/CommitCheck/$name.php"; + $cls = "MTrackCommitCheck_$name"; + self::$listeners[] = new $cls; + } + + + var $repo; + var $bridge; + + function __construct($repo) { + $this->repo = $repo; + } + + 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)) { + require_once 'MTrack/Exception/Veto.php'; + throw new MTrackVetoException($reasons); + } + } + + 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(?:$cmds))\s*(?P$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 = 'ref'; // 'close'; - commits need reviewing before they can close something. + } 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) + { + //echo "Pre-commit"; + $this->bridge = $bridge; + MTrackACL::requireAllRights("repo:" . $this->repo->repoid, 'commit'); + + + $files = $bridge->enumChangedOrModifiedFileNames(); + + $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) { + require_once 'MTrack/Exception/Veto.php'; + throw new MTrackVetoException($reasons); + } + $this->checkVeto('vetoCommit', $log, $files, $actions, $this); + } + + private function _getChanges(IMTrackCommitHookBridge $bridge) + { + $changes = array(); + if ($bridge instanceof IMTrackCommitHookBridge2) { + // this is HG only at present. + $changes = $bridge->getChanges(); + } else { + require_once 'MTrack/CommitHookChangeEvent.php'; + $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); + } + // print_r($deferred); + 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(); + } + + +} diff --git a/MTrack/CommitHookChangeEvent.php b/MTrack/CommitHookChangeEvent.php new file mode 100644 index 00000000..dd49598c --- /dev/null +++ b/MTrack/CommitHookChangeEvent.php @@ -0,0 +1,21 @@ +deleted ? '' : '') . + htmlentities($this->name , ENT_QUOTES, 'utf-8') . + ($this->deleted ? '' : ''); + + } + + + 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 = ?', + $this->compid)->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); + } + } + + + +} \ No newline at end of file diff --git a/MTrack/Config.php b/MTrack/Config.php new file mode 100644 index 00000000..ddfb58b2 --- /dev/null +++ b/MTrack/Config.php @@ -0,0 +1,161 @@ + $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); + } + } + static function checkInitializing() + { + if (file_exists(MTrackConfig::get('core', 'vardir') . '/.initializing')) { + echo "System not set up yet"; + exit(0); + } + } + +} + diff --git a/MTrack/DB.php b/MTrack/DB.php new file mode 100644 index 00000000..b8750cbb --- /dev/null +++ b/MTrack/DB.php @@ -0,0 +1,132 @@ +format('Y-m-d\TH:i:s.u\Z'); + } + + static function get() + { + + if (self::$db != null) { + return self::$db; + } + + + $dsn = MTrackConfig::get('core', 'dsn'); + $user = MTrackConfig::get('core', 'dsnuser'); + $pass = MTrackConfig::get('core', 'dsnpassword'); + if ($dsn === null) { + $dsn = 'sqlite:' . MTrackConfig::get('core', 'dblocation'); + } + $db = new PDO($dsn, $user, $pass); + $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(); + } + } + + + // bit like stored procs in SQLITE.. + 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) // escape.. + { + 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 "
SQL: $sql\n"; + # var_dump($params); + #echo "
"; + 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; + } +} \ No newline at end of file diff --git a/MTrack/DBSchema.php b/MTrack/DBSchema.php new file mode 100644 index 00000000..20f1a9cc --- /dev/null +++ b/MTrack/DBSchema.php @@ -0,0 +1,80 @@ +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; + } + } + } +} diff --git a/MTrack/DBSchema/Generic.php b/MTrack/DBSchema/Generic.php new file mode 100644 index 00000000..0d5856f2 --- /dev/null +++ b/MTrack/DBSchema/Generic.php @@ -0,0 +1,107 @@ +db = $db; + } + + function determineVersion() { + //echo "RUNNING QUERY"; + try { + $q = $this->db->query('select version from mtrack_schema'); + if ($q) { + foreach ($q as $row) { + return $row[0]; + } + } + } catch (Exception $e) { + // print_r($e->toString()); + } + 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(); + } + } + + 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"); + } +} \ No newline at end of file diff --git a/MTrack/DBSchema/SQLite.php b/MTrack/DBSchema/SQLite.php new file mode 100644 index 00000000..1cde3542 --- /dev/null +++ b/MTrack/DBSchema/SQLite.php @@ -0,0 +1,105 @@ +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', + 'longtext' => 'text' + ); + + 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 ("; + $new_names = array(); + $old_names = array(); + foreach ($from->fields as $f) { + + if (!isset($to->fields[$f->name])) { + // to_field does not exist in new schema. + // look for it. + foreach( $to->fields as $tf) { + if (!empty($tf->oldname) && $tf->oldname == $f->name) { + $new_names[] = $f->name; + $old_names[] = $f->oldname; + break; + } + } + continue; + } + $new_names[] = $f->name; + $old_names[] = $f->name; + } + $sql .= join(', ', $new_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"); + } + + +} \ No newline at end of file diff --git a/MTrack/DBSchema/Table.php b/MTrack/DBSchema/Table.php new file mode 100644 index 00000000..3f47f45e --- /dev/null +++ b/MTrack/DBSchema/Table.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/MTrack/DBSchema/mysql.php b/MTrack/DBSchema/mysql.php new file mode 100644 index 00000000..d10de2c3 --- /dev/null +++ b/MTrack/DBSchema/mysql.php @@ -0,0 +1,156 @@ + 'INT(11) NOT NULL AUTO_INCREMENT', + 'timestamp' => 'datetime', + 'blob' => 'longtext', // eak what blob is stored? + 'text' => 'VARCHAR(128)', + ); + + + function determineVersion() { + $ret = parent::determineVersion(); + if ($ret) { + return $ret; + } + parse_str(str_replace(array(";",":"), "&", $this->db->dsn), $dsn); + + try { + $this->db->query("CREATE DATABASE {$dsn['dbname']}"); + } catch (Exception $e) { + // technically this should not get here.. + // as pdo's dsn design is borked.. + } + + + // assume we need to create database.. + 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)) { + return $str; + } + if (!strlen($f->default)) { + return $str . " DEFAULT ''"; + } + + return $str . ($f->default == 'CURRENT_TIMESTAMP' ? '' : 'DEFAULT '. $f->default); + } + + // may as well rewrite this.. as we have too loop throug hit anyway.. + 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 $kn=>$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); + } + function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to) + { + $sql = array(); + $actions = array(); + + //print_r($to); + /* 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 PRIMARY KEY"; + } else { + $actions[] = "DROP INDEX $k->name"; + } + } + } + + foreach ($from->fields as $f) { + + if (isset($to->fields[$f->name])) { + continue; + } + // let's check to see if it got renamed.. + $found = false; + foreach($to->fields as $ff) { + + if ($ff->oldname == $f->name) { + $actions[] = "CHANGE COLUMN `$f->name` " . $this->computeFieldCreate($ff); + unset($to->fields[$ff->name]); + $found = true; + } + } + if ($found) continue; + $actions[] = "DROP COLUMN $f->name"; + + + } + 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); + } + } +} \ No newline at end of file diff --git a/MTrack/DBSchema/pgsql.php b/MTrack/DBSchema/pgsql.php new file mode 100644 index 00000000..d88e4ff9 --- /dev/null +++ b/MTrack/DBSchema/pgsql.php @@ -0,0 +1,69 @@ + '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); + } + } +} \ No newline at end of file diff --git a/MTrack/DataObjects/Event.php b/MTrack/DataObjects/Event.php new file mode 100644 index 00000000..b6f687a4 --- /dev/null +++ b/MTrack/DataObjects/Event.php @@ -0,0 +1,469 @@ +tid}",$start, $limit); + */ + // limit will not really work due to the later merging.. + $q = MTrackDB::q( + 'select * from changes where object = ? + order by changedate asc', + "ticket:{$issue->tid}"); + + foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $CS) { + $changes[$CS['cid']] = self::init($CS); + $changes[$CS['cid']]->issue = $issue; + $cids[] = $CS['cid']; + } + + $cidlist = join(',', $cids); + + $q= MTrackDB::q("select * from change_audit where cid in ($cidlist)"); + foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $citem) { + + $changes[$citem['cid']]->audit[] = $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. */ + + + $q = 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:{$issue->tid}:%' + "); + + foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $CS) { + + if (!isset($changes[$CS['cid']])) { + $changes[$CS['cid']] = self::init($CS); + $changes[$CS['cid']]->issue = $issue; + } + + $changes[$CS['cid']]->audit[] = $CS; + } + + foreach (MTrackDB::q( + "select * from effort where cid in ($cidlist) and tid=?", $issue->tid) + ->fetchAll(PDO::FETCH_ASSOC) as $eff) { + $changes[$eff['cid']]->effort[]= $eff; + } + return $changes; + } + + static function init($ar) + { + $r = new MTrack_DataObject_Event; + foreach($ar as $k=>$v) { + $r->$k = $v; + } + return $r; + } + + function toHtml() + { + + /* + +
+ #3 - COMMIT + 5 days ago + - + www-data + - + Fix #21- deal table page control button not stable (new defect) +
+ + + ... + */ + + $link = HTML_FlexyFramework::get()->page->link; + $preamble = 0; + $cid = "comment:{$this->cid}"; + + // tidied up by jquery.. + $timestamp = $link->date($this->changedate, false); + + + $comments = array(); + + // default is that something changed.. + $type = 'Changed'; + + $comment_body = ''; + + $comment_title = ''; + $comment_fields = array(); + + foreach ($this->audit as $citem) { + //print_r($citem); + $main = false; + + $ar = explode(':', $citem['fieldname'], 3); + if (count($ar) != 3) { + continue; + } + list($tbl,,$field) = $ar; + + if ($tbl != 'ticket') { + // can get here if we created a new keyword, for example + //var_dump($citem); + continue; + } + + switch($field) { + case '@comment': + $comments[] = $citem['value']; + $type = 'Comment added'; + $comment_title = array_shift(explode("\n", $citem['value'])); + continue; + + case 'spent': + continue; + + + + case '@components': + $comment_fields[] = $field; + $ar = MTrackComponent::loadByIds($citem['value']); + $ar = array_map( function($e) { return $e->toHtml(); }, $ar); + $citem['value'] = join(', ', $ar); + + + break; + + case '@milestones': + $comment_fields[] = $field; + $citem['value'] = $this->get_milestones_list($citem['value']); + break; + + case '@keywords': + + + $comment_fields[] = $field; + $ar = MTrackKeyword::loadByIds($citem['value']); + $ar = array_map( function($e) { return $e->toHtml(); }, $ar); + $citem['value'] = join(', ', $ar); + break; + + case 'estimated': + $comment_fields[] = $field; + if ($citem['value'] !== null) { + $citem['value'] += 0; + } + if ($citem['oldvalue'] !== null) { + $citem['oldvalue'] += 0; + } + break; + + default: + $comment_fields[] = $field; + if ($field[0] == '@') { + $main = isset($pseudo_fields[$field]) ? $pseudo_fields[$field] : ''; + $field = substr($field, 1, -1); + } else { + $main = $this->issue->$field; + } + + } + + + require_once 'MTrack/Ticket_CustomFields.php'; + $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'] || $this->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 ($this->issue->description == $citem['value']) { + $comment_body .= "Description: no longer empty; see above
"; + continue; + } + + $initial_lines = count(explode("\n", $this->issue->description)); + $diff = $this->diff_strings($this->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) { + $comment_body .= "initial $label
" . + MTrack_Wiki::format_to_html($citem['value']); + } else { + $diff = $this->collapse_diff($diff); + $comment_body .= "initial $label (diff to above):
$diff\n"; + } + } else { + $comment_body .= "$label $citem[value]
\n"; + } + } + continue; + } + + if ($citem['action'] == 'changed') { + $lines = explode("\n", $citem['value'], 3); + if (count($lines) >= 2) { + $diff = $this->diff_strings($citem['oldvalue'], $citem['value']); + $diff = $this->collapse_diff($diff); + $comment_body .= "$label $citem[action]\n$diff\n"; + } else { + $comment_body .= "$label $citem[action] to $citem[value]
\n"; + } + continue; + } + + $comment_body .= "$label $citem[action]
\n"; + + } + + $commit_info = array(); + if ($comment_title && + preg_match('/\(In \[changeset:([^,]+),([a-z0-9]+)\]\)(.*)$/i', $comment_title, $commit_info)) { + $type = 'Commit'; + + + $comment_title = '[' . $commit_info[2] . ']'.$commit_info[3]; + + + } + if ($this->cid == $this->issue->created) { + $type = 'Created'; + $comment_title = 'Issue Created'; + } + + + + + + $html = ' +
+ + ' . $type.' + #'.$this->cid.' + + ' . + (strlen($comment_title) ? $comment_title : implode(', ', $comment_fields)) . ' + + + ' . + $link->username($this->who, array('no_image' => true, 'fullname' => true)) . ' + - '.$timestamp.' + + +
+
' . + $link->username($this->who, array('no_name' => true, 'size' => 48)); + + + + + foreach ($this->effort as $eff) { + $exp = (float)$eff['expended']; + if ($eff['expended'] != 0) { + $comment_body .= "spent $exp hours
\n"; + $preamble++; + } + } + + + if ($preamble) { + $html .= "
\n"; + $preamble = 0; + } + + foreach ($comments as $cid => $text) { + // look for changesets in the comments.. + // and display them as expandable linsk.. + $html .= MTrack_Wiki::format_to_html($text); + } + + + $html .= '
'; + + + + if ($commit_info) { + // list the files that where changed... + //$html.= print_r($M, true); + $rp = '/' . $commit_info[1] . '/' . $commit_info[2]; + $fullrp = $rp; + $repo = MTrackSCM::factory($rp); + $cd = $repo->historyWithChangelog(null, 1, 'rev', $rp); + //print_r($cd);exit; + + + if (!is_array($cd->ent->files) || !count($cd->ent->files)) { + return $html; + } + + $num = count(array_keys($cd->ent->files)); + $map = array( + 'D' => 'DELETED', + 'M' => 'MODIFIED', + 'A' => 'ADDED' + ); + + $file_summary = array(); + foreach($map as $k=>$v) { + $summary[$v] = 0; + } + foreach ($cd->ent->files as $id => $file) { + + + $type = isset($map[$file->status]) ? $map[$file->status] : '??? '. $file->status; + + if ($num > 10) + { + // will bork on unknown... + $summary[$type]++; + continue; + } + + + $html .= ' +
+ + ' . $type.' + ' . + $file->nameToHtml() . ' + + [View Diff] + [View File] + [View History] +
+
+ '; + + + + } + + if ($num> 10) { + $ar = array(); + foreach($summary as $k=>$v) { + if (empty($v)) { + continue; + } + $ar[] = $v . ' ' . $k; + } + $html .= ' +
+ + MULTIPLE FILES: + ' . + implode(', ', $ar) . ' + + [View Diff] + [View File] + [View History] +
+
+ '; + + } + + //$html .= print_r($cd, true); + + } + + + return $html ; + + + } + + function collapse_diff($diff) + { + static $idnum = 1; + require_once 'MTrackWeb/Changeset.php'; + $cs = new MTrackWeb_Changeset(); + $id = 'diff_' . $idnum++; + return "
" . + "". + ""; + } + + function 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); + static $diff = false; + if (!$diff) { + require_once 'System.php'; + $diff= System::which('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; + } + + +} \ No newline at end of file diff --git a/MTrack/DataObjects/Userinfo.php b/MTrack/DataObjects/Userinfo.php new file mode 100644 index 00000000..10f7cff9 --- /dev/null +++ b/MTrack/DataObjects/Userinfo.php @@ -0,0 +1,104 @@ +/', $name, $M)) { + $name = $M[1]; + } + + // things we can look for.. + // userid ? + // email.. (userinfo) + + $q = MTrackDB::q(' select userid from userinfo where userid = ? OR email= ?',$name,$name); + $ar = $q->fetchAll(); + if (count($ar)) { + return MTrack_DataObjects_UserInfo::get($ar[0]['userid']); + } + // alias + $q = MTrackDB::q(' select userid from useraliases where alias = ?',$name); + $ar = $q->fetchAll(PDO::FETCH_ASSOC); + if ($ar) { //? empty array? + return MTrack_DataObjects_UserInfo::get($ar[0]['userid']); + } + return false; + } + + static function get($uid) + { + if ($uid == 'anonymous') { + $ret = new Mtrack_DataObjects_UserInfo(); + $ret->userid = 'anonymous'; + return $ret; + } + $q = MTrackDB::q( 'select * from userinfo where userid = ?', $uid); + + foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $row) { + return self::init($row); + } + throw new Exception("User does not exist"); + } + + static function init($ar) + { + $r = new Mtrack_DataObjects_UserInfo; + foreach($ar as $k=>$v) { + $r->$k = $v; + } + return $r; + } + static function selectList($ret) + { + $q = MTrackDB::q( 'select userid, fullname, active from userinfo order by active DESC, userid ASC' ); + // inactive are show after active... + foreach ($q->fetchAll() as $row) { + + $disp = strlen($row[1]) ? "$row[0] - $row[1]" : $row[0]; + $disp .= $row[2] ? '' : ' (inactive)'; + + $ret[$row[0]] = $disp; + + } + return $ret; + + } + + /** + * setup date_default_timezone_set(); + */ + function setCurrentTimeZone() + { + if (!empty($this->timezone)) { + date_default_timezone_set($this->timezone); + return; + } + $ff = HTML_FlexyFramework::get(); + if (isset($ff->MTrack['timezone'])) { + date_default_timezone_set($ff->MTrack['timezone']); + } + + } + + +} \ No newline at end of file diff --git a/MTrack/Enumeration.php b/MTrack/Enumeration.php new file mode 100644 index 00000000..cdbd13d8 --- /dev/null +++ b/MTrack/Enumeration.php @@ -0,0 +1,84 @@ +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); + + } +} + + +/// various enumerations.. + + + \ No newline at end of file diff --git a/MTrack/Exception/Authorization.php b/MTrack/Exception/Authorization.php new file mode 100644 index 00000000..166fa554 --- /dev/null +++ b/MTrack/Exception/Authorization.php @@ -0,0 +1,8 @@ +rights = $rights; + } +} \ No newline at end of file diff --git a/MTrack/Exception/DB.php b/MTrack/Exception/DB.php new file mode 100644 index 00000000..e69de29b diff --git a/MTrack/Exception/Veto.php b/MTrack/Exception/Veto.php new file mode 100644 index 00000000..82008a0e --- /dev/null +++ b/MTrack/Exception/Veto.php @@ -0,0 +1,11 @@ +reasons = $reasons; + parent::__construct(join("\n", $reasons)); + } +} \ No newline at end of file diff --git a/MTrack/Interface/Auth.php b/MTrack/Interface/Auth.php new file mode 100644 index 00000000..f22f12b6 --- /dev/null +++ b/MTrack/Interface/Auth.php @@ -0,0 +1,50 @@ +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) // used by CustomFields + { + self::$_listeners[] = $l; + } + 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"; + } + } + } + + //methods.. + + 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(); + + $row = null; + if (isset($data[0])) { + $row = $data[0]; + } + + 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; + } + } + require_once 'Exception/Veto.php'; + 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(); + + require_once 'UUID.php'; + $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(); + } + + function getComponents() + { + if ($this->components !== null) { + return $this->components ; + } + $q = MTrackDB::q(' + select tc.compid, name + from ticket_components tc + left join components using (compid) + where tid = ?', $this->tid); + + $this->origcomponents = array(); + foreach ($q->fetchAll() as $row) { + $this->origcomponents[$row[0]] = $row[1]; + } + $this->components = $this->origcomponents; + + return $this->components; + } + + 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; + } + + + 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; + } + } + + + 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; + } + + + function toArray() + { + $ret = get_object_vars($this); + // echo '
'; print_r($ret);exit;
+        return $ret;
+    }
+  
+  
+  /// rendering tricks
+  
+  
+    function updateWho($link)
+    {
+        return  $link->username(
+                $this->updated ? 
+                    MTrackChangeset::get($this->updated)->who :
+                    MTrackAuth::whoami()
+        );
+        
+    }
+    function updateWhen($link) 
+    {
+        return  $link->date( $this->updated ? 
+                MTrackChangeset::get($this->updated)->when :
+                MTrackDB::unixtime(time()) 
+        );
+    }
+    function createdWho($link)
+    {
+        return  $link->username(
+                $this->created ? 
+                    MTrackChangeset::get($this->created)->who :
+                    MTrackAuth::whoami()
+        );
+        
+    }
+    function createdWhen($link) 
+    {
+        return  $link->date( $this->created ? 
+                MTrackChangeset::get($this->created)->when :
+                MTrackDB::unixtime(time()) 
+        );
+    }
+  
+    function keywordsToHtml() 
+    {
+        $value = array();
+        foreach ($this->getKeywords() as $kw) {
+            $value[] = mtrack_keyword($kw);
+        }
+        return  join(' ', $value);
+    }
+    
+
+    
+    function componentsToHtml() 
+    {
+        $res = array();
+        $value = join(',',array_keys($this->getComponents()));
+        
+        if (!strlen($value)) {
+            return '';
+        }
+        $q = MTrackDB::q(
+              "select name, deleted from components where compid in ($value)");
+        foreach ($q->fetchAll() as $row) {
+            
+            $c = ($row['deleted'] ? '' : '') .
+                htmlentities($row['name'], ENT_QUOTES, 'utf-8') . 
+                ($row['deleted'] ? '' : '');
+            $res[] = $c;
+        }
+        
+        return join(", ", $res);
+       
+    }
+    
+    function milestonesToHtml($msurl) 
+    {
+        if (empty($this->milestone_url)) {
+            die("requires issue->milestone_url to be set.");
+        }
+        $res = array();
+        $value = join(',',array_keys($this->getMilestones()));
+        
+        if (!strlen($value)) {
+            return '';
+        }
+        foreach (MTrackDB::q(
+          "select name, completed, deleted from milestones where mid in ($value)")
+          ->fetchAll() as $row) {
+              
+            $row['deleted'] =   strlen($row['completed']) ? 1 : 0;
+                
+            $c = "milestone_url}" . urlencode($row['name']) . '">' .
+                htmlentities($row['name'], ENT_QUOTES, 'utf-8') .
+                "";
+            $res[] = $c;
+        }
+        return join(", ", $res);
+    }
+    function descriptionToHtml()
+    {
+        return MTrack_Wiki::format_to_html($this->description);
+    }
+   /* function attachmentsToHtml()
+    {
+        require_once 'Attachment.php';
+        return MTrackAttachment::renderList("ticket:{$this->tid}");
+    }
+    function attachmentsDeleteToHtml() {
+        require_once 'Attachment.php';
+        return MTrackAttachment::renderDeleteList("ticket:{$this->tid}");
+    }
+ */
+    function toIdString() {
+        return "ticket:{$this->tid}";
+            
+    }
+    
+    // watcher related.
+    function watcherButton()
+    {
+        return MTrackWatch::renderWatchUI('ticket', $this->tid);
+    }
+    
+    function watchType() 
+    {
+        return 'ticket';
+    }
+    function watchId()
+    {
+        return $this->tid;
+    }
+    
+    function watchers()
+    {
+        return MTrackWatch::objectWatchers($this);
+    }
+    
+    
+    private function resolveMilestone($ms)
+    {
+        if ($ms instanceof MTrack_Milestone) {
+          return $ms;
+        }
+        if (ctype_digit($ms)) {
+          return MTrack_Milestone::loadById($ms);
+        }
+        return MTrack_Milestone::loadByName($ms);
+    }
+
+  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;
+  }
+  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);
+    }
+  }
+  private function resolveComponent($comp)
+  {
+    if ($comp instanceof MTrackComponent) {
+      return $comp;
+    }
+    if (ctype_digit($comp)) {
+      return MTrackComponent::loadById($comp);
+    }
+    return MTrackComponent::loadByName($comp);
+  }
+}
diff --git a/MTrack/Keyword.php b/MTrack/Keyword.php
new file mode 100644
index 00000000..83646272
--- /dev/null
+++ b/MTrack/Keyword.php
@@ -0,0 +1,58 @@
+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 loadByIds($value) 
+    {
+        $ar = explode(',', $value);
+        $ret = array();
+        foreach($ar as $k) {
+            if (empty($k)) {
+                continue;
+            }
+            $ret[] = new MTrackKeyword($k);
+        }
+        return $ret;
+    }
+    function toHtml()
+    {
+        return  htmlentities($this->keyword, ENT_QUOTES, 'utf-8') ;
+            
+    }
+   
+  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/MTrack/Milestone.php b/MTrack/Milestone.php
new file mode 100644
index 00000000..ad091c0a
--- /dev/null
+++ b/MTrack/Milestone.php
@@ -0,0 +1,380 @@
+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 = MTrack_Milestone::loadByName($params['milestone']);
+    if (!$m) {
+      return "BurnDown: milestone $params[milestone] is invalid
\n"; + } + if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) { + return "Not authorized to view milestone $name
\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 = " +
+ +"; + + $delta = $init_estimate - $total_exp; + + return + "
Initial estimate: $init_estimate, Work expended: $total_exp
\n" + . $html . "
"; + } + + static function macro_MilestoneSummary($name) { + global $ABSWEB; + + $m = self::loadByName($name); + if (!$m) { + return "milestone: " . htmlentities($name) . " not found
\n"; + } + + if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) { + return "Not authorized to view milestone $name
\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 = MTrack_Wiki::format_to_html($description); + $pname = $name; + if ($m->completed !== NULL) { + $pname = "$name"; + $due = "Completed"; + } elseif ($m->duedate) { + $due = "Due " . mtrack_date($m->duedate); + } else { + $due = null; + } + + $watch = MTrackWatch::getWatchUI('milestone', $m->mid); + + $html = << +

$pname

+$watch +
$due
+$desc
+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; + + if ($open) { + $html .= << +HTML; + } + + $ms = urlencode($name); + + $html .= << + +$open open, +$closed closed, +$total total ($cpct % complete) + +HTML; + return $html; + } +} + diff --git a/MTrack/Priority.php b/MTrack/Priority.php new file mode 100644 index 00000000..8aa0e36b --- /dev/null +++ b/MTrack/Priority.php @@ -0,0 +1,14 @@ +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; + } +} \ No newline at end of file diff --git a/MTrack/Repo.php b/MTrack/Repo.php new file mode 100644 index 00000000..b62f9c8f --- /dev/null +++ b/MTrack/Repo.php @@ -0,0 +1,518 @@ + $classname) { + $o = new $classname; + $ret[$t] = $o; + } + return $ret; + } + 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; + } + static function loadByLocation($path) + { + + // we have magic configuration - end users commit into SVN + // backend is really git... - so pre-commit hooks have to be from svn + 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; + } + + static function loadByChangeSet($cs) + { + + static $re = array(); + if (isset($re[$cs])) { + return $re[$cs]; + } + //using (repoid) ?? + $q = MTrackDB::q(" + select + r.shortname as repo, + p.shortname as proj + from + repos r + left join + project_repo_link l on r.repoid = l.repoid + left join + projects p on p.projid = r.projectid + where + (parent is null or length(parent) = 0) + AND + ( + ( ? like CONCAT(proj), '%') + OR + ( ? like CONCAT(repo), '%') + ) + "); + $ar = $q->fetchAll(PDO::FETCH_ASSOC); + if ($ar) { + $re[$cs] = self::loadByName($ar['repo']); + return $re[$cs]; + } + $re[$cs] = false; + return $re[$cs]; + } + + // methods + + 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 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); + } + + function getSCMMetaData() { + 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; + } + + function getWorkingCopy() { + throw new Exception("cannot getWorkingCopy from a generic repo object"); + } + + 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; + } + + function getBranches() {} + function getTags() {} + function readdir($path, $object = null, $ident = null) {} + function file($path, $object = null, $ident = null) {} + function history($path, $limit = null, $object = null, $ident = null){} + function diff($path, $from = null, $to = null) {} + 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; + } + + function historyWithChangelog($path, $limit = null, $object = null, $ident = null) + { + + $ents = $this->history($path, $limit, $object, $ident); + $data = new StdClass; + if (!count($ents)) { + $data->ent = null; + return $data; + } + $ent = $ents[0]; + $data->ent = $ent; + + // Determine project from the file list + $the_proj = $this->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 historyWithChangelogAndDiff($path, $limit = null, $object = null, $ident = null) + { + $ret = $this->historyWithChangelog($path, $limit, $object, $ident); + if (!$ret->ent) { + return $ret; + } + if (!is_array($ret->ent->files)) { + return $ret; + } + foreach ($ret->ent->files as $file) { + // where is mtrack_diff... + $file->diff = mtrack_diff($this->diff($file, $ret->ent->rev)); + } + + + return $data; + } + // rendering.. + + function displayName() + { + // fixme - this code needs to be in here.. rather than in SCM? + return MTrackSCM::makeDisplayName($this); + } + function descriptionToHtml() + { + return MTrack_Wiki::format_to_html($this->description); + } + + static function defaultRepo($cfg = null) + { + static $defrepo = null; + if ($defrepo !== null) { + return $defrepo; // already to it.. + } + + $defrepo = $cfg; + if ($defrepo !== null) { + $defrepo = strpos($defrepo, '/') === false ? 'default/' . $defrepo : $defrepo; + return $defrepo; + } + + $defrepo = ''; + $q = MTrackDB::q( 'select parent, shortname from repos order by shortname'); + foreach($q->fetchAll() as $row) { + $defrepo = MTrackSCM::makeDisplayName($row); + return $defrepo; + } + return ''; + + } + +} diff --git a/MTrack/Report.php b/MTrack/Report.php new file mode 100644 index 00000000..231790f6 --- /dev/null +++ b/MTrack/Report.php @@ -0,0 +1,642 @@ +fetchAll(); + if (isset($row[0])) { + return new MTrack_Report($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); + + } + } + + function render() + { + return self::renderReport($this->query); + } + + + + static function renderReport($repstring, $passed_params = null, $format = 'html') + { + //global $ABSWEB; + if (empty(MTrack_Wiki_HTMLFormatter::$linkHandler)) { + die("MTrack_Wiki_HTMLFormatter::\$link handler not set up"); + } + + 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 "
" . $e->getMessage() . "
" . + htmlentities($repstring, ENT_QUOTES, 'utf-8') . "
"; + } + + $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 .= "\n"; + } + } + if ($format == 'html' && isset($row['__group__'])) { + $out .= "

" . + htmlentities($row['__group__'], ENT_COMPAT, 'utf-8') . + "

\n"; + $group = $row['__group__']; + } + + if ($format == 'html') { + $out .= ""; + } + + 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 .= ""; + } else if ($format == 'tab') { + $out .= "$caption\t"; + } + } elseif ($name[0] == '_') { + continue; + } else { + if ($format == 'html') { + $out .= ""; + $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_Wiki_HTMLFormatter::$linkHandler->date($v); + } + break; + case 'content': + $v = MTrack_Wiki::format_to_html($v); + break; + case 'owner': + $v = MTrack_Wiki_HTMLFormatter::$linkHandler->username($v, array('no_image' => true)); + break; + case 'docid': + case 'ticket': + // print_r($row); + $v = MTrack_Wiki_HTMLFormatter::$linkHandler->ticket($row['ticket']); // what about doc id? + break; + case 'summary': + + $v = isset($row['ticket']) ? + MTrack_Wiki_HTMLFormatter::$linkHandler->ticket($row['ticket'], array('display' => $v)) : + $v= htmlspecialchars($v); + break; + case 'milestone': + $oldv = $v; + $v = ''; + foreach (preg_split("/\s*,\s*/", $oldv) as $m) { + if (!strlen($m)) continue; + $v .= MTrack_Wiki_HTMLFormatter::$linkHandler->milestone($m); + /* + $v .= "" . + "" . + htmlentities($m, ENT_QUOTES, 'utf-8') . + " "; + */ + } + break; + case 'keyword': + $oldv = $v; + $v = ''; + foreach (preg_split("/\s*,\s*/", $oldv) as $m) { + if (!strlen($m)) continue; + $v .= MTrack_Wiki_HTMLFormatter::$linkHandler->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 .= "$begin_row$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 .= ""; + if (substr($name, -1) == '_') { + $out .= "$begin_row"; + } + } else if ($format == 'tab') { + $out .= "$v\t"; + } + } + } + if ($format == 'html') { + $out .= "\n"; + } else if ($format == 'tab') { + $out .= "\n"; + } + } + if ($format == 'html') { + $out .= "
$caption
$v
$v
"; + } 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( + 'MTrack_Report', '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 .= << '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); + + + } +}; + + + diff --git a/MTrack/Resolution.php b/MTrack/Resolution.php new file mode 100644 index 00000000..f39a3efb --- /dev/null +++ b/MTrack/Resolution.php @@ -0,0 +1,14 @@ +repopath = $repopath; + 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 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(); + + /** 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 ''; + } + + + + + /* 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; + } + + /** + * was run tool... + */ + + static function run($toolname, $mode, $args = null) + { + global $FORKS; //??? why? + + static $tools; // we cache the lookups... - we could use the config for this... (as per original..) + // but that would only be needed or realy heavily loaded sites. + + require_once 'System.php'; + $tool = isset($tools[$toolname]) ? $tools[$toolname] : System::which($toolname); + $tools[$toolname] = $tool; + if (empty($tool)) { + throw new Exception("Could not find '$toolname'"); + } + + $cmd = $tool; + $args = is_array($args) ? $args : array(); + + foreach ($args as $arg) { + if (!is_array($arg)) { + $cmd .= ' ' . escapeshellarg($arg); + continue; + } + + foreach ($arg as $a) { + $cmd .= ' ' . escapeshellarg($a); + } + } + + if (!isset($FORKS[$cmd])) { + $FORKS[$cmd] = 0; + } + $FORKS[$cmd]++; + + // debugging.... + if (false) { + if (php_sapi_name() == 'cli') { + echo $cmd, "\n"; + } else { + error_log($cmd); + echo htmlentities($cmd) . "
\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); + } + } +} diff --git a/MTrack/SCM/Git/CommitHookBridge.php b/MTrack/SCM/Git/CommitHookBridge.php new file mode 100644 index 00000000..2b6b8b7f --- /dev/null +++ b/MTrack/SCM/Git/CommitHookBridge.php @@ -0,0 +1,110 @@ + delete / modify etc.. + /** + * fills up repo, files, log, commits by running log on the STDIN + */ + function __construct(MTrackRepo $repo) + { + self::$GIT = MTrackConfig::get('tools', 'git'); + $this->repo = $repo; + while (($line = fgets(STDIN)) !== false) { + + list($old, $new, $ref) = explode(' ', trim($line), 3); + $this->commits[] = $new; + + $fp = $this->run(self::$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"); + } + // read key: value properties like Author: / Date: + 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]; + } + } + // read the commit log. + 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; + $this->fileActions[$file] = $st; + } + + } while (($line = fgets($fp)) !== false); + } + } + + + function enumChangedOrModifiedFileNames() + { + $ret = array(); + foreach($this->files as $f=>$com) { + if ($this->fileActions[$f] == 'D') { + continue; + } + $ret[] = $f; + } + return $ret; + } + + 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) + { + $rev = $this->files[$path]; + + // There may be a better way... + // ls-tree to determine the hash of the file from this change: + $fp = $this->run(self::$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 $this->run(self::$GIT, 'cat-file', 'blob', $hash); + } + + function getChangesetDescriptor() + { + $cs = array(); + foreach ($this->commits as $ref) { + $cs[] = '[changeset:' . $this->repo->getBrowseRootName() . ",$ref]"; + } + return join(", ", $cs); + } +} \ No newline at end of file diff --git a/MTrack/SCM/Git/Event.php b/MTrack/SCM/Git/Event.php new file mode 100644 index 00000000..ef7ce60f --- /dev/null +++ b/MTrack/SCM/Git/Event.php @@ -0,0 +1,127 @@ +commit = $commit; + $ent->repo = $repo; + $lines = explode("\n", $commit); + $line = array_shift($lines); + + if (!preg_match("/^commit\s+(\S+)$/", $line, $M)) { + return false; + } + $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); + } + // this should only be the last set of lines.. + + foreach ($lines as $line) { + if (!strlen($line)) { + continue; + } + + if (preg_match('#^:#', $line)) { + // it's our stat line..: + // :100755 100755 fde93abd1a71accd3aa7e97b29c1eecfb43095d7 + // 3d71edf6512035846d8164c3b28818de0062335a M web/MTrackWeb/DataObjects/Changes.php + $info = preg_split('#\s+#', substr($line ,1), 6); + // print_r($info); + $f = new MTrackSCMFileEvent; //generic.. + $f->name = $info[5]; + $f->oldperm = $info[0]; + $f->newperm = $info[1]; + $f->oldver = $info[2]; + $f->newver = $info[3]; + $f->status = $info[4]; + $ent->files[$f->name] = $f; + continue; + } + + $info = preg_split('#\s+#', substr($line ,1), 3); + //print_r($info); + $name = $info[2]; + $ent->files[$name]->added = $info[0]; + $ent->files[$name]->removed = $info[1]; + + } + // fixme.. + if (!count($ent->branches)) { + $ent->branches[] = 'master'; + } + + return $ent; + } + + +} + \ No newline at end of file diff --git a/MTrack/SCM/Git/File.php b/MTrack/SCM/Git/File.php new file mode 100644 index 00000000..2b50df9a --- /dev/null +++ b/MTrack/SCM/Git/File.php @@ -0,0 +1,120 @@ +repo = $repo; + $this->name = $name; + $this->rev = $rev; + $this->is_dir = $is_dir; + $this->hash = $hash; + } + + public function getChangeEvent() // returns MTrackSCMEvent + { + + // var_Dump($this->repoid); + $q = MTrackDB::q('SELECT * FROM clcache where + repoid = ? AND rev = ?' , $this->repo->repoid, $this->hash ); + + $ar = $q->fetchAll(PDO::FETCH_ASSOC); + if (!empty($ar)) { + require_once 'MTrack/SCM/Git/Event.php'; + $ro = MTrack_SCM_Git_Event::newFromCommit($ar[0]['sobject'], $this->repo); + // var_dump("RETURNING FROM DB"); + return $ro; + } + + $ent = $this->repo->history($this->name, 1, 'rev', $this->rev); + + if ($ent) { + MTrackDB::q('INSERT INTO clcache (repoid, rev, sobject) VALUES ( ? ,? ,? )', + $this->repo->repoid, $this->hash , $ent[0]->commit + ); + + } + + + + return $ent ? $ent[0] : false; + } + + 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; + print($wc);exit; + $fp = $wc->git('annotate', '-p', $this->rev, '--', $this->name,); + } else { + */ + + + $fp = $this->repo->git('annotate', '-p', $this->rev, '--', $this->name); + + $i = 1; + $ann = array(); + $meta = array(); + + while ($line = fgets($fp)) { + // echo htmlentities($line), "
\n"; + if (!strncmp($line, "\t", 1)) { + $A = new MTrackSCMAnnotation; + $A->lineno = $i; + $A->repo = $this->repo; + 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; + } +} + + + diff --git a/MTrack/SCM/Git/Repo.php b/MTrack/SCM/Git/Repo.php new file mode 100644 index 00000000..8a4378e0 --- /dev/null +++ b/MTrack/SCM/Git/Repo.php @@ -0,0 +1,363 @@ + '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 = MTrackSCM::run('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 = MTrackSCM::run('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 = MTrackSCM::run('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 = MTrackSCM::run('git', 'read', + array('add', '.gitignore')); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git add .gitignore failed: $out"); + } + $stm = MTrackSCM::run('git', 'read', + array('commit', '-a', '-m', 'init')); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git commit failed: $out"); + } + $stm = MTrackSCM::run('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 = <<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(); + require_once 'MTrack/SCM/Git/File.php'; + while ($line = fgets($fp)) { + // blob = file, tree = dir.. + list($mode, $type, $hash, $name) = preg_split("/\s+/", $line); + //echo '
';echo $line ."\n
"; + $res[] = new MTrack_SCM_Git_File($this, "$name", $rev, $type == 'tree', $hash); + } + 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); + require_once 'MTrack/SCM/Git/File.php'; + return new MTrack_SCM_Git_File($this, $path, $rev); + } + + /** + * + * @param string path (can be empty - eg. '') + * @param {number|date} limit how many to fetch + * @param {string} object = eg. rev|tag|branch (use 'rev' here and ident=HASH to retrieve a speific revision + * @param {string} ident = + * + */ + public function history($path, $limit = null, $object = null, $ident = null) + { + + + $res = array(); + + $args = array(); + if ($object !== null) { + $rev = $this->resolveRevision(null, $object, $ident); // from scm... + $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[] = "--raw"; + $args[] = "--no-abbrev"; + $args[] = "--numstat"; + $args[] = "--date=rfc"; + + + //echo '
';print_r($args);echo '
'; + $path = ltrim($path, '/'); + //print_R(array($args, '--' ,$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; + } + require_once 'MTrack/SCM/Git/Event.php'; + foreach ($commits as $commit) { + $res[] = MTrack_SCM_Git_Event::newFromCommit($commit, $this); + } + $fp = null; + + return $res; + } + + public function diff($path, $from = null, $to = null) + { + require_once 'MTrack/SCMFile.php'; + + if ($path instanceof MTrackSCMFile) { + if ($from === null) { + $from = $path->rev; + } + $path = $path->name; + + } + // if it's a file event.. we are even lucker.. + if ($path instanceof MTrackSCMFileEvent) { + return $this->git('log', '--max-count=1', '--format=format:', '--patch', $from, '--', $path->name); + + } + + + if ($to !== null) { + return $this->git('diff', "$from..$to", '--', $path); + } + return $this->git('diff', "$from^..$from", '--', $path); + } + + public function getWorkingCopy() + { + require_once 'MTrack/SCM/Git/WorkingCopy.php'; + return new MTrack_SCM_Git_WorkingCopy($this); + } + + public function getRelatedChanges($revision) // pretty nasty.. could end up with 1000's of changes.. + { + $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) { + // print_r(array($this->gitdir , $this->repopath)); + + //$a[] = "--work-tree=$this->repopath"; + } + foreach ($args as $arg) { + if (is_array($arg)) { + $a = array_merge($a, $arg); + continue; + } + $a[] = $arg; + } + var_dump('git ' . join (' ' , $a)); + //print_r($a); + return MTrackSCM::run('git', 'read', $a); + } +} + + diff --git a/MTrack/SCM/Git/WorkingCopy.php b/MTrack/SCM/Git/WorkingCopy.php new file mode 100644 index 00000000..8f728731 --- /dev/null +++ b/MTrack/SCM/Git/WorkingCopy.php @@ -0,0 +1,68 @@ +dir = mtrack_make_temp_dir(); + $this->repo = $repo; + + MTrackSCM::run('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; + } + print_r($a); + return MTrackSCM::run('git', 'read', $a); + } +} diff --git a/MTrack/SCM/Hg.php b/MTrack/SCM/Hg.php new file mode 100644 index 00000000..be0a76a2 --- /dev/null +++ b/MTrack/SCM/Hg.php @@ -0,0 +1,476 @@ +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() + { + + //FIXME - this should do something similar to git, + // and use the database rather than caching with expiry.. + return MTrackSCMFileHg::_determineFileChangeEvent($this->repo->repoid, $this->name, $this->rev); + //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) = MTrackSCM::run('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 MTrackSCM::run('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 = MTrackSCM::run('hg', 'read', array( + 'clone', $S->repopath, $r->repopath)); + } else { + $stm = MTrackSCM::run('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->repo = $this; + $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 MTrackSCM::run('hg', 'read', $a); + } +} + + diff --git a/MTrack/SCM/Svn.php b/MTrack/SCM/Svn.php new file mode 100644 index 00000000..22c9a486 --- /dev/null +++ b/MTrack/SCM/Svn.php @@ -0,0 +1,474 @@ +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() + { + //FIXME - this should do something similar to git, + // and use the database rather than caching with expiry.. + return MTrackSCMFileSVN::_determineFileChangeEvent($this->repo->getBrowseRootName(), $this->name, $this->rev); + //return mtrack_cache( + // array('MTrackSCMFileHg', '_determineFileChangeEvent'), + // array($this->repo->repoid, $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) = MTrackSCM::run('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 = MTrackSCM::run('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 = MTrackSCM::run('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 '
', htmlentities($ls, ENT_QUOTES, 'utf-8'), '
'; + } + 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 '
', htmlentities($xml, ENT_QUOTES, 'utf-8'), '
'; + return null; + } + if (self::$debug) { + if (php_sapi_name() == 'cli') { + echo $xml, "\n"; + } else { + echo htmlentities(var_export($xml, true)) . "
"; + } + } + $origpath = $path; + if ($origpath[0] != '/') { + $origpath = '/' . $origpath; + } + if ($doc->logentry) foreach ($doc->logentry as $le) { + $matched = false; + $ent = new MTrackSCMEvent; + $ent->repo = $this; + $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 MTrackSCM::run('svn', 'read', $args); + } +} + + diff --git a/MTrack/SCM/Svn/CommitHookBridge.php b/MTrack/SCM/Svn/CommitHookBridge.php new file mode 100644 index 00000000..e9321f18 --- /dev/null +++ b/MTrack/SCM/Svn/CommitHookBridge.php @@ -0,0 +1,60 @@ +repo = $repo; + $this->svnlook = MTrackConfig::get('tools', 'svnlook'); + $this->svnrepo = $svnrepo; + $this->svntxn = explode(' ', $svntxn,2); + } + + function enumChangedOrModifiedFileNames() + { + $files = array(); + $fp = $this->run($this->svnlook, 'changed', $this->svntxn, $this->svnrepo); + 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 = $this->run($this->svnlook, 'log', $this->svntxn, $this->svnrepo); + + $log = stream_get_contents($fp); + $log = preg_replace('/\[(\d+)\]/', + "[changeset:" . $this->repo->getBrowseRootName() . ",\$1]", $log); + return $log; + } + + function getDiffStream($path) + { + return $this->run($this->svnlook, 'diff', $this->svntxn, $this->svnrepo, $path); + } + + function getFileStream($path) + { + return $this->run($this->svnlook, 'cat', $this->svntxn, $this->svnrepo, $path); + } + + function getChangesetDescriptor() + { + $rev = $this->svntxn[1]; + return '[changeset:' . $this->repo->getBrowseRootName() . ",$rev]"; + } +} diff --git a/MTrack/SCMAnnotation.php b/MTrack/SCMAnnotation.php new file mode 100644 index 00000000..0cd88bb3 --- /dev/null +++ b/MTrack/SCMAnnotation.php @@ -0,0 +1,30 @@ +changeset($this->rev, $this->repo); + + } + function changebyToHtml($link) { + return $link->username($this->changeby, + array('no_image' => true)); + } + + +} diff --git a/MTrack/SCMEvent.php b/MTrack/SCMEvent.php new file mode 100644 index 00000000..a6aeb107 --- /dev/null +++ b/MTrack/SCMEvent.php @@ -0,0 +1,50 @@ +changelog); + return MTrack_Wiki::format_to_oneliner(rtrim($one, " \r\n")); + } + function changelogToHtml() + { + return MTrack_Wiki::format_to_html($ent->changelog); + } + + function changeByToHtml($linkHandler) + { + return $linkHandler->username($this->changeby, array('size' => 16)); + //mtrack_username($d->changeby, array('size' => 16)) <<< might add size here as an arg.. + } + function ctimeToHtml($linkHandler) + { + return $linkHandler->date($this->ctime); + } + function changeset($linkHandler) + { + return $linkHandler->changeset($this->rev, $this->repo); + } + +} \ No newline at end of file diff --git a/MTrack/SCMFile.php b/MTrack/SCMFile.php new file mode 100644 index 00000000..36bbc45d --- /dev/null +++ b/MTrack/SCMFile.php @@ -0,0 +1,42 @@ +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); + + + + +} \ No newline at end of file diff --git a/MTrack/SCMFileEvent.php b/MTrack/SCMFileEvent.php new file mode 100644 index 00000000..4fd44104 --- /dev/null +++ b/MTrack/SCMFileEvent.php @@ -0,0 +1,41 @@ +name; + } + + function changesToHtml() + { + switch($this->status) { + case 'D': return 'Deleted'; + case 'M': return 'Changed lines : ' . ( $this->added ? '+' .$this->added : '') . ' ' . ( $this->removed ? '-' .$this->removed : ''); + case 'A': return 'Added : ' . ( $this->added ? '+' .$this->added : '') ; + default : '??' . $this->status; + } + } + + function nametoHtml() + { + return + ($this->status == 'D' ? '' : '') . + htmlspecialchars($this->name) . + ($this->status == 'D' ? '' : ''); + } +} \ No newline at end of file diff --git a/MTrack/SCMWorkingCopy.php b/MTrack/SCMWorkingCopy.php new file mode 100644 index 00000000..63c77465 --- /dev/null +++ b/MTrack/SCMWorkingCopy.php @@ -0,0 +1,39 @@ +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); + } + } +} diff --git a/MTrack/SearchDB.php b/MTrack/SearchDB.php new file mode 100644 index 00000000..d11e2ef3 --- /dev/null +++ b/MTrack/SearchDB.php @@ -0,0 +1,74 @@ +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/MTrack/SearchResult.php b/MTrack/SearchResult.php new file mode 100644 index 00000000..c7de4456 --- /dev/null +++ b/MTrack/SearchResult.php @@ -0,0 +1,17 @@ +excerpt; + } +} \ No newline at end of file diff --git a/MTrack/Severity.php b/MTrack/Severity.php new file mode 100644 index 00000000..c4168a06 --- /dev/null +++ b/MTrack/Severity.php @@ -0,0 +1,16 @@ +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 + ); + } + } +} + + diff --git a/MTrack/SyntaxHighlight.php b/MTrack/SyntaxHighlight.php new file mode 100644 index 00000000..a01d5f88 --- /dev/null +++ b/MTrack/SyntaxHighlight.php @@ -0,0 +1,133 @@ + 'No syntax highlighting', + 'plain' => "Plain", + '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 "$hdata"; + } + $lines = preg_split("/\r?\n/", $data); + $html = << + + line + code + +HTML; + $nlines = count($lines); + for ($i = 1; $i <= $nlines; $i++) { + $html .= "$i"; + if ($i == 1) { + $html .= "$hdata"; + } + $html .= "\n"; + } + return $html . "\n"; + } + + static function getSchemeSelect($selected = 'plain') { + $html = << +HTML; + foreach (self::$schemes as $k => $v) { + $sel = $selected == $k ? " selected" : ''; + $html .= "\n"; + } + return $html . ""; + } + + static function getLangSelect($name, $value) { + return mtrack_select_box($name, self::$langs, $value); + } + +} + diff --git a/MTrack/TicketState.php b/MTrack/TicketState.php new file mode 100644 index 00000000..41c2fac0 --- /dev/null +++ b/MTrack/TicketState.php @@ -0,0 +1,13 @@ +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; + } +} diff --git a/MTrack/Ticket_CustomFields.php b/MTrack/Ticket_CustomFields.php new file mode 100644 index 00000000..3ecb4128 --- /dev/null +++ b/MTrack/Ticket_CustomFields.php @@ -0,0 +1,175 @@ + '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, MTrack_Milestone $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/MTrack/UUID.php b/MTrack/UUID.php new file mode 100644 index 00000000..bcdb05e2 --- /dev/null +++ b/MTrack/UUID.php @@ -0,0 +1,158 @@ +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/MTrack/Watch.php b/MTrack/Watch.php new file mode 100644 index 00000000..3953e52d --- /dev/null +++ b/MTrack/Watch.php @@ -0,0 +1,501 @@ + 'Email', + // 'timline' => 'Timeline' + ); + + static function init($ar) + { + $r = new MTrackWatch; + foreach($ar as $k=>$v) { + $r->$k = $v; + } + return $r; + } + + 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 <<Watch + +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); + } + // mostly used to force sign up by assigned person.. + // MTrackWatch::watch_object('ticket', xxxx, user); + static function watch_object($objname, $id, $user) + { + + $db = MTrackDB::get(); + MTrackDB::q('delete from watches where otype = ? and oid = ? and userid = ?', + $objname, $id, $user); + + $evts = self::$possible_event_types[$objname]; + + foreach ($evts as $evt) { + MTrackDB::q('insert into watches (otype, oid, userid, medium, event, active) values (?, ?, ?, ?, ?, 1)', + $objname, $id, $user, 'email', $evt); + + } + + } + + static function objectWatchersNameId($objname, $objid) + { + require_once 'MTrack/DataObjects/Userinfo.php'; + + $q = MTrackDB::q('select distinct(userid) from watches where otype = ? and oid = ?', + $objname, $objid + ); + $ret = array(); + foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $CS) { + // print_r($CS); + $ret[] = MTrack_DataObjects_Userinfo::get($CS['userid']); + } + + return $ret; + + } + + static function objectWatchers($object) + { + return self::objectWatchersNameId( $object->watchType(), $object->watchId()); + + } +} + diff --git a/MTrack/Wiki.php b/MTrack/Wiki.php new file mode 100644 index 00000000..5dd6bdaf --- /dev/null +++ b/MTrack/Wiki.php @@ -0,0 +1,99 @@ +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("/^.*/sm", '', $html); + $html = preg_replace(",.*,sm", '', $html); + } + } + return $html; + } + + static function format_to_oneliner($text) { + $f = new MTrack_Wiki_OneLinerFormatter; + $f->format($text); + return $f->out; + } + static function format_wiki_page($name) { + $d = MTrack_Wiki_Item::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 = ''; + 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 .= ''; + foreach ($cols as $c) { + $res .= "\n"; + } + $res .= ""; + } else { + if (is_string($next_row)) { + array_unshift($content, $next_row); + } + // regular row + $res .= ""; + foreach ($cols as $c) { + $res .= "\n"; + } + $res .= "\n"; + } + } + $res .= "
" . htmlentities($c, ENT_QUOTES, 'utf-8') . "
" . htmlentities($c, ENT_QUOTES, 'utf-8') . "
\n"; + return $res; + } +} \ No newline at end of file diff --git a/MTrack/Wiki/HTMLFormatter.php b/MTrack/Wiki/HTMLFormatter.php new file mode 100644 index 00000000..a2e172ae --- /dev/null +++ b/MTrack/Wiki/HTMLFormatter.php @@ -0,0 +1,948 @@ +parser = new MTrack_Wiki_Parser; + } + + static function registerLinkHandler(MTrack_Interface_WikiLinkHandler $li) + { + self::$linkHandler = $li; + } + + + 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) == MTrack_Wiki_Parser::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 .= "
\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(",
\s*$,", $line)) { + $sep = "
\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 = MTrack_Wiki::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('%s', + $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('', ''); + $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, '', ''); + } + function _italic_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + function _underline_formatter($match, $info) { + $this->simple_tag_handler($match, + '', ''); + } + function _strike_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + function _subscript_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + function _superscript_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + + function _email_formatter($match, $info) { + $this->out .= "" . htmlspecialchars($match, ENT_COMPAT, 'utf-8') . ""; + } + + function _htmlspecialcharsape_formatter($match, $info) { + $this->out .= htmlspecialchars($match, ENT_QUOTES, 'utf-8'); + } + + + + function _make_link($ns, $target, $match, $label) { + + 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 .= self::$linkHandler->ticket($target, array( + 'display' => $label, + '#' => $anchor, + )); + return; + + case 'changeset': + if (strpos($target, ',') !== false) { + list($repo, $cs) = explode(',', $target, 2); + $this->out .= self::$linkHandler->changeset($cs, $repo); + return; + } + $this->out .= self::$linkHandler->changeset($target); + return; + + case 'milestone': + $this->out .= self::$linkHandler->milestone($target); + return; + + case 'wiki': + $this->out .= self::$linkHandler->wiki($target, array( + '#' => $anchor, + 'display' => $label + )); + return; + + case 'help': + $this->out .= self::$linkHandler->help($target,$label,$anchor); + return; + + case 'user': + $this->out .= self::$linkHandler->username($target); + return; + + case 'repo': + $this->out .= self::$linkHandler->browse($target,$label); + return; + + case 'log': + if ($target == '/') { + $target = MtrackRepo::defaultRepo( + empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo'] + ); ///??? + } + $this->out .= $this->log($target, $label); + break; + + case 'query': + case 'report': + $this->out .= self::$linkHandler->{$ns}($target,$label); + return; + + 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) { + $target = MtrackRepo::defaultRepo( + empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo'] + ); ///??? + 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, '/'); + $out .= self::$linkHandler->file($file . ($rev ? '@'. $rev : '')); + return; + + + case 'comment': + if (preg_match('/^(\d+):ticket:(.*)$/', $target, $M)) { + $this->out .= self::$linkHandler->ticket($M[2], + array( + '#' => 'comment:' . $M[1], + 'display' => $label + ) + ); + return; + } + $this->out .= '' .htmlspecialchars($label). ''; + return; + + case 'http': + if (strlen($anchor)) { + $target .= "#$anchor"; + } + $this->out .= '' .htmlspecialchars($label). ''; + return; + case 'file': // not sure if this should be supported... + $this->out .= '' .htmlspecialchars($label). ''; + return; + default: + throw new Exception("unknown target " . $ns); + $target = "$ns:$target"; + if (strlen($anchor)) { + $target .= "#$anchor"; + } + break; + } + } + + } + + + 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 .= "$label"; + } else { + $this->_make_link($ns, $target, $match, $label); + } + } + + function _inlinecode_formatter($match, $info, $nmatch) { + $this->out .= "" . + nl2br(htmlspecialchars($info['inline'][$nmatch][0], + ENT_COMPAT, 'utf-8')) . + ""; + } + function _inlinecode2_formatter($match, $info, $nmatch) { + $this->out .= "" . + nl2br(htmlspecialchars($info['inline2'][$nmatch][0], + ENT_COMPAT, 'utf-8')) . + ""; + } + + function _macro_formatter($match, $info, $nmatch) { + $name = $info['macroname'][$nmatch][0]; + if (!strcasecmp($name, 'br')) { + $this->out .= "
"; + return; + } + if (isset(MTrack_Wiki::$macros[$name])) { + $args = explode(',', $info['macroargs'][$nmatch][0]); + $this->out .= call_user_func_array(MTrack_Wiki::$macros[$name], $args); + } else { + $this->out .= "" . + htmlspecialchars($match, ENT_QUOTES, 'utf-8') . ""; + } + } + + + 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>
  • "; + } + private function _close_list($type) { + array_pop($this->list_stack); + $this->out .= "
  • "; + } + + 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 .= "
  • "; + } + } 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 .= "\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 .= "\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 .= "

    \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 .= "

    \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>"; + 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 .= "\n"; + } + } + + function open_table_row() { + if (!$this->in_table_row) { + $this->open_table(); + if ($this->table_row_count == 0) { + $this->out .= ""; + } else if ($this->table_row_count == 1) { + $this->out .= ""; + } else { + $this->out .= ""; + } + $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 .= ""; + } + if ($this->table_row_count == 1) { + $this->out .= ""; + } else { + $this->out .= ""; + } + } + } + + function close_table() { + if ($this->in_table) { + $this->close_table_row(); + if ($this->table_row_count == 1) { + $this->out .= "
    \n"; + } else { + $this->out .= "\n"; + } + $this->in_table = 0; + } + } + + function close_def_list() { + if ($this->in_def_list) { + $this->out .= "\n"; + } + $this->in_def_list = false; + } + + function handle_code_block($line) { + if (trim($line) == MTrack_Wiki_Parser::STARTBLOCK) { + $this->in_code_block++; + if ($this->in_code_block == 1) { + $this->code_buf = array(); + } else { + $this->code_buf[] = $line; + } + } elseif (trim($line) == MTrack_Wiki_Parser::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(MTrack_Wiki::$processors[$M[1]])) { + $func = MTrack_Wiki::$processors[$M[1]]; + array_shift($this->code_buf); + $this->out .= call_user_func($func, $M[1], $this->code_buf); + } else { + $this->out .= "
    " .
    +            htmlspecialchars(join("\n", $this->code_buf), ENT_COMPAT, 'utf-8') .
    +            "
    "; + } + } else { + $this->code_buf[] = $line; + } + } else { + $this->code_buf[] = $line; + } + } + + function close_code_blocks() { + while ($this->in_code_block) { + $this->handle_code_block(MTrack_Wiki_Parser::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 ? '' : '
    '; + list($def) = explode('::', $match, 2); + $tmp .= sprintf("
    %s
    ", + MTrack_Wiki::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); + } + + +} diff --git a/MTrack/Wiki/Item.php b/MTrack/Wiki/Item.php new file mode 100644 index 00000000..ff57bf6d --- /dev/null +++ b/MTrack/Wiki/Item.php @@ -0,0 +1,146 @@ +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(); + } + + static function index_item($object) + { + list($ignore, $ident) = explode(':', $object, 2); + $w = MTrack_Wiki_Item::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; + } + function __get($name) { + if ($name == 'content') { + $this->content = stream_get_contents($this->file->cat()); + return $this->content; + } + } + 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); + } + + function toHTML() + { + return MTrack_Wiki::format_to_html($this->content); + } +} + diff --git a/MTrack/Wiki/OneLinerFormatter.php b/MTrack/Wiki/OneLinerFormatter.php new file mode 100644 index 00000000..a26ed32a --- /dev/null +++ b/MTrack/Wiki/OneLinerFormatter.php @@ -0,0 +1,32 @@ +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) == MTrack_Wiki_Parser::STARTBLOCK) { + $in_code_block++; + } elseif (trim($line) == MTrack_Wiki_Parser::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); + } + } +} diff --git a/MTrack/Wiki/Parser.php b/MTrack/Wiki/Parser.php new file mode 100644 index 00000000..588b8726 --- /dev/null +++ b/MTrack/Wiki/Parser.php @@ -0,0 +1,143 @@ +\s])"; + const SHREF_TARGET_LAST = "[\w/=](?!?%s)", self::BOLDITALIC_TOKEN), + array("(?P!?%s)" , self::BOLD_TOKEN), + array("(?P!?%s)" , self::ITALIC_TOKEN), + array("(?P!?%s)" , self::UNDERLINE_TOKEN), + array("(?P!?%s)" , self::STRIKE_TOKEN), + array("(?P!?%s)" , self::SUBSCRIPT_TOKEN), + array("(?P!?%s)" , self::SUPERSCRIPT_TOKEN), + array("(?P!?%s(?P.*?)%s)" , + self::STARTBLOCK_TOKEN, self::ENDBLOCK_TOKEN), + array("(?P!?%s(?P.*?)%s)", + self::INLINE_TOKEN, self::INLINE_TOKEN), + ); + static $post_rules = array( + # WikiPageName + array("(?P!?(?!?\[\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!?\[(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+)\])", + # #ticket + "(?P!?#(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+))", + # {report} + "(?P!?\{([^}]*)\})", + + # e-mails + array("(?P!?%s)" , self::EMAIL_LOOKALIKE_PATTERN), + # > ... + "(?P^(?P>(?: *>)*))", + # &, < and > to &, < and > + "(?P[&<>])", + # wiki:TracLinks + array( + "(?P!?((?P%s):(?P%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!?\[(?:(?P%s)|(?P%s):(?P%s|[^\]\s]*))(?:\s+(?P
  • "; +} +$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; + +if (strlen(trim($qs))) { + echo MTrack_Report::macro_TicketQuery($qs); +} + +mtrack_foot(); + diff --git a/MTrackWeb/Report.php b/MTrackWeb/Report.php new file mode 100644 index 00000000..7bb30db0 --- /dev/null +++ b/MTrackWeb/Report.php @@ -0,0 +1,100 @@ +edit = !empty($_REQUEST['edit']); + + if ($this->edit && !$pi) { + MTrackACL::requireAllRights('Reports', 'create'); + $rep = new MTrack_Report; + } + // only support id??? + if ($pi) { + $rep = MTrack_Report::loadByID($pi); + MTrackACL::requireAllRights("report:" . $rep->rid, $this->edit ? 'modify' : 'read'); + } + + ///$rep = MTrack_Report::loadBySummary($pi); + ///MTrackACL::requireAllRights("report:" . $rep->rid, $edit ? 'modify' : 'read'); + + if (isset($_GET['format'])) { + // targeted report format; omit decoration <<< ??? tab delimited only at present? + $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; + } + + $this->title = "Create Report"; + if ($pi) { + $this->title = $this->edit ? + ('{' . $rep->rid . '} ' . $rep->summary . " (edit)") : + '{' . $rep->rid . '} ' . $rep->summary; + } + $this->canModify = MTrackACL::hasAllRights("report:" . $rep->rid, 'modify'); + $this->rep = $rep; + + MTrack_Report::$link = $this->link; + + } + + function post($pi=0) + { + $pi = (int) $pi; + if (!$pi) { + MTrackACL::requireAllRights('Reports', 'create'); + $rep = new MTrack_Report; + } + // only support id??? + if ($pi) { + $rep = MTrack_Report::loadByID($pi); + MTrackACL::requireAllRights("report:" . $rep->rid, 'modify'); + } + $rep->summary = $_POST['name']; + $rep->description = $_POST['description']; + $rep->query = $_POST['query']; + if (isset($_POST['cancel'])) { + header("Location: {$this->baseURL}/Reports"); + exit; + } + // in theory... everything else is a 'save'... + try { + $cs = MTrackChangeset::begin( + "report:" . $rep->summary, $_POST['comment']); + $rep->save($cs); + $cs->commit(); + return $this->get($pi); + } catch (Exception $e) { + $this->message = $e->getMessage(); + } + return $this->get($pi); + + + } + + + + +} \ No newline at end of file diff --git a/MTrackWeb/Reports.php b/MTrackWeb/Reports.php new file mode 100644 index 00000000..4fff4dfb --- /dev/null +++ b/MTrackWeb/Reports.php @@ -0,0 +1,69 @@ +rows = $q->fetchAll(PDO::FETCH_OBJECT); + $this->canCreate = MTrackACL::hasAllRights('Reports', 'create'); + + } +} + + + ?> +

    Available Reports

    + +

    + The reports below are constructed using SQL. You may also + use the Custom Query + page to create a report on the fly. +

    + + + + + + +fetchAll(PDO::FETCH_ASSOC) as $row) +{ + $url = "${ABSWEB}report.php/$row[rid]"; + $t = "{" . $row['rid'] . "}"; + $s = htmlentities($row['summary'], ENT_COMPAT, 'utf-8'); + $s = "$s"; + + echo << +HTML; +} +?> +
    ReportTitle
    $t$s
    + +
    + +
    +Roadmap +
    +
    + +
    + +
    +
    +
    + + + +
    +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 = <<query($sql)->fetchAll(PDO::FETCH_ASSOC) as $row) { + echo MTrack_Milestone::macro_MilestoneSummary($row['name']); + $i++; +} + +if ($i == 0) { + $milestones = $watched == 'checked' ? 'watched milestones' : 'milestones'; + echo <<No $milestones were found.

    +HTML; +} + +mtrack_foot(); + diff --git a/MTrackWeb/Setup/init.php b/MTrackWeb/Setup/init.php new file mode 100644 index 00000000..a30b2385 --- /dev/null +++ b/MTrackWeb/Setup/init.php @@ -0,0 +1,527 @@ + $link[0]\n"; + } + } +} +echo "\n"; + +if (count($tracs)) { + foreach ($tracs as $tname => $pname) { + echo "Import trac $name -> $pname\n"; + } +} + +function usage($msg = '') +{ + echo $msg, << $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 MTrack_Report; + $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/MTrackWeb/Setup/make-authorized-keys.php b/MTrackWeb/Setup/make-authorized-keys.php new file mode 100644 index 00000000..9a395df4 --- /dev/null +++ b/MTrackWeb/Setup/make-authorized-keys.php @@ -0,0 +1,73 @@ +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/MTrackWeb/Setup/schema-tool.php b/MTrackWeb/Setup/schema-tool.php new file mode 100644 index 00000000..436b5563 --- /dev/null +++ b/MTrackWeb/Setup/schema-tool.php @@ -0,0 +1,145 @@ +dsn = $dsn; // wez learn to design api's ;) +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$driver = $db->getAttribute(PDO::ATTR_DRIVER_NAME); +$adapter_class = "MTrackDBSchema_$driver"; +$adapter = new $adapter_class($dsn); + +$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/MTrackWeb/Snippet.php b/MTrackWeb/Snippet.php new file mode 100644 index 00000000..f7b9dc98 --- /dev/null +++ b/MTrackWeb/Snippet.php @@ -0,0 +1,143 @@ +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 MTrack_Snippet; + $snip->description = $_POST['description']; + $snip->lang = $_POST['lang']; + $snip->snippet = $_POST['code']; +} elseif (strlen($pi)) { + $snip = MTrack_Snippet::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 ""; + + +/* 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 << +Snippets are a way to share text or code fragments

    +HTML; + +if (MTrackACL::hasAllRights('Snippets', 'create')) { + echo <<New Snippet
    + +HTML; +} + +echo <<Recent Snippets +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 << + $sum
    + $when by $who
    + view snippet + +HTML; +} +echo "
    "; + +if (MTrackACL::hasAllRights('Snippets', 'create') && + (!$snip || $_SERVER['REQUEST_METHOD'] == 'POST')) { + echo "
    "; + echo "\n"; + echo MTrack_SyntaxHighlight::getLangSelect('lang', $lang); + echo "

    "; + echo "\n"; + echo "\n"; + echo "
    "; +} + +if ($snip) { + echo "
    "; + echo "

    Snippet

    "; + if ($snip->created) { + $created = MTrackChangeset::get($snip->created); + } else { + $created = new stdclass; + } + + echo "", + mtrack_username($created->who, array('no_name' => true, 'size' => 48)), + ""; + echo "Created: ", + mtrack_date($created->when), + " by ", + mtrack_username($created->who, array('no_image' => true)), + "
    \n"; + echo "Link to this snippet
    "; + + echo MTrackWiki::format_to_html($snip->description); + echo "

    "; + echo MTrack_SyntaxHighlight::getSchemeSelect(); + echo MTrack_SyntaxHighlight::highlightSource($code, $lang, null, true); + echo "
    "; +} else if (!MTrackACL::hasAllRights('Snippets', 'create')) { + echo "

    You do not have rights to create snippets

    "; +} + +echo "
    "; + +mtrack_foot(); diff --git a/MTrackWeb/Ticket.php b/MTrackWeb/Ticket.php new file mode 100644 index 00000000..b66d469f --- /dev/null +++ b/MTrackWeb/Ticket.php @@ -0,0 +1,544 @@ +authUser = MTrack_DataObjects_Userinfo::get(MTrackAuth::whoami()); + + $this->id = $pi ? $pi: (isset($_GET['id']) ? $_GET['id'] : 0); + $this->id = $this->id == 'new' ? 0 : $this->id; + + + + // -- load issue.. + + $this->issue = new MTrackIssue; + $this->issue->priority = 'normal'; + if ($this->id) { + $this->issue = (strlen($this->id) == 32) ? + MTrackIssue::loadById($this->id) : + MTrackIssue::loadByNSIdent($this->id); + } + if (!$this->issue) { + throw new Exception("Invalid ticket $this->id"); + } + + $this->tid = $this->id ? $this->issue->tid : 0; + + + $this->issue->augmentFormFields($this->fieldset()); + + + $this->preview = false; + $this->error = array(); + + $this->rights(); + + + $this->issue->milestoneURL = $this->baseURL.'/milestone.php'; // fix me later.. + + $this->showEditBar = false; + + if ($this->editable && $this->id != 'new' && !$this->preview) { + $this->showEditBar = true; + } + + $this->initEditForm(); + + + + } + + function post($pi=0) // handle the post... + { + + $this->get($pi); + + if (isset($_POST['cancel'])) { + header("Location: {$$this->baseURL}/Ticket/$this->issue->nsident"); + exit; + } + + if (!MTrack_Captcha::check('ticket')) { + $this->error[] = "CAPTCHA failed, please try again"; + } + $this->preview = isset($_POST['preview']) ? true : false; + + $comment = ''; + try { + if (!$this->id) { + MTrackACL::requireAllRights("Tickets", 'create'); + } else { + MTrackACL::requireAllRights("ticket:" . $this->issue->tid, 'modify'); + } + } catch (Exception $e) { + $this->error[] = $e->getMessage(); + } + + if (!$this->id) { + $comment = empty($_POST['comment']) ? '' : $_POST['comment']; + } + + if (!strlen($comment)) { + $comment = $_POST['summary']; + } + try { + $CS = MTrackChangeset::begin("ticket:X", $comment); + } catch (Exception $e) { + $this->error[] = $e->getMessage(); + $CS = null; + } + if (!$this->id) { + // 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(); + + + + switch($db->getAttribute(PDO::ATTR_DRIVER_NAME)) { + case '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+$'"; + break; + + case 'mysql': + $max = "select max(cast(nsident as UNSIGNED)) + 1 from tickets"; + break; + + default: + $max = 'select max(cast(nsident as integer)) + 1 from tickets'; + break; + } + + + + list($this->issue->nsident) = MTrackDB::q($max)->fetchAll(PDO::FETCH_COLUMN, 0); + if ($this->issue->nsident === null) { + $this->issue->nsident = 1; + } + } + + if (isset($_POST['action']) && !$this->preview) { + $act= explode('_', $_POST['action'] , 2); + //var_dump($act);exit; + switch ($act[0]) { + case 'leave': + break; + case 'reopen': + $this->issue->reOpen(); + break; + case 'fixed': + $this->issue->resolution = 'fixed'; + $this->issue->close(); + $_POST['estimated'] = $this->issue->estimated; + break; + + + case 'accept': + // will be applied to the issue further down + $_POST['owner'] = MTrackAuth::whoami(); + if ($this->issue->status == 'new') { + $this->issue->status = 'open'; + } + break; + + + case 'resolve': + //$this->issue->resolution = $_POST['resolution']; + $this->issue->resolution = $act[1]; + $this->issue->close(); + $_POST['estimated'] = $this->issue->estimated; + break; + + case 'change': + $this->issue->status = $act[1]; + break; + } + } + + $fields = array( + 'summary', + 'description', + 'classification', + 'priority', + 'severity', + 'changelog', + 'owner', + 'cc', + ); + + $this->issue->applyPOSTData($_POST); + + + + foreach ($fields as $fieldname) { + if (isset($_POST[$fieldname]) && strlen($_POST[$fieldname])) { + $this->issue->$fieldname = $_POST[$fieldname]; + } else { + $this->issue->$fieldname = null; + } + } + + $kw = $this->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); + } + $this->issue->assocKeyword($k); + } else { + $w = array_search($w, $kill); + if ($w !== false) { + unset($kill[$w]); + } + } + } + foreach ($kill as $w) { + $this->issue->dissocKeyword($w); + } + + $ms = $this->issue->getMilestones(); + $kill = $ms; + if (isset($_POST['milestone']) && is_array($_POST['milestone'])) { + foreach ($_POST['milestone'] as $mid) { + $this->issue->assocMilestone($mid); + unset($kill[$mid]); + } + } + foreach ($kill as $mid) { + $this->issue->dissocMilestone($mid); + } + + $ms = $this->issue->getComponents(); + $kill = $ms; + if (isset($_POST['component']) && is_array($_POST['component'])) { + foreach ($_POST['component'] as $mid) { + $this->issue->assocComponent($mid); + unset($kill[$mid]); + } + } + foreach ($kill as $mid) { + $this->issue->dissocComponent($mid); + } + + if (!empty($_POST['comment'])) { + $this->issue->addComment($_POST['comment']); + } + + $this->issue->addEffort( + empty($_POST['spent']) ? 0 : $_POST['spent'], + empty($_POST['estimate']) ? 0 : $_POST['estimate'] + ); + + if (!count($this->error)) { + try { + $this->issue->save($CS); + + // make sure everyone is watching it!!!! + if($this->issue->owner && $this->issue->tid) { + // make sure owner is tracking it... + MTrackWatch::watch_object('ticket', $this->issue->tid, $this->issue->owner); + } + + if ($this->id == 'new') { + MTrackWatch::watch_object('ticket', $this->issue->tid, MTrackAuth::whoami()); + } + + + $CS->setObject("ticket:" . $this->issue->tid); + } catch (Exception $e) { + $this->error[] = $e->getMessage(); + } + } + + if (!count($this->error)) { + if (!empty($_FILES['attachments'])) { + require_once 'MTrack/Attachment.php'; + foreach ($_FILES['attachments']['name'] as $fileid => $name) { + + MTrackAttachment::add("ticket:{$this->issue->tid}", + $_FILES['attachments']['tmp_name'][$fileid], + $_FILES['attachments']['name'][$fileid], + $CS + ); + } + } + } + if (!count($this->error) && $this->id != 'new') { + require_once 'MTrack/Attachment.php'; + MTrackAttachment::process_delete("ticket:{$this->issue->tid}", $CS); + } + + if (isset($_POST['apply']) && !count($this->error)) { + $CS->commit(); + header("Location: {$this->baseURL}/Ticket/{$this->issue->nsident}"); + exit; + } + } + + function fieldset() // depreciated... eventually... + { + return 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" => $this->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" + ), + ), + ); + } + + function initEditForm($params = array()) + { + require_once 'HTML/Template/Flexy/Element.php'; + require_once 'HTML/Template/Flexy/Factory.php'; + $this->elements = array(); + + require_once 'MTrack/Classification.php'; + require_once 'MTrack/Priority.php'; + require_once 'MTrack/Severity.php'; + require_once 'MTrack/Resolution.php'; + + foreach(array( 'classification', 'priority', 'severity', 'resolution' ) as $c) { + $cls = 'Mtrack'. $c; + $C = new $cls; + $ar = $C->enumerate(); + $this->elements[$c] = new HTML_Template_Flexy_Element('select'); + $this->elements[$c]->setOptions($C->enumerate()); + + } + + + + $r = array(); + $q = 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'); + + foreach ($q->fetchAll(PDO::FETCH_NUM) as $row) { + + $r[$row[0]] = strlen($row[2]) ? $row[1] . " ($row[2])" : $row[1]; + + } + // print_r($r); + + $this->elements['component[]'] = new HTML_Template_Flexy_Element('select'); + $this->elements['component[]']->setOptions($r); + // compenets + $ar = $this->issue->getComponents(); + $this->elements['component[]']->setValue(array_keys($ar)); + + + + $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 ($this->issue->getMilestones() as $mid => $name) { + if (!isset($r[$mid])) { + $r[$mid] = $name; + } + } + $this->elements['milestone[]'] = new HTML_Template_Flexy_Element('select'); + $this->elements['milestone[]']->setOptions($r); + + + + + // FIXME: workflow should be able to influence this list of users + $users = array(); + $inactiveusers = array(); + require_once 'MTrack/DataObjects/Userinfo.php'; + $users = MTrack_DataObjects_Userinfo::selectList(array(''=>'nobody')); + + // last ditch to have it show the right info + if (!isset($users[$this->issue->owner])) { + $users[$this->issue->owner] = $this->issue->owner; + } + + $this->elements['owner'] = new HTML_Template_Flexy_Element('select'); + $this->elements['owner']->setOptions($users); + + + + // keywords -- in toArray... + // milestone + + + $this->change_status = array(); + $this->resolve_status = array(); + if ($this->id) { + + // for coder's they can only change this ticke to certian states + + //print_r($groups); + // Nasty - I really do not like the acl code in this ... + require_once 'MTrack/TicketState.php'; + + $ST = new MTrackTicketState; + $ST = $ST->enumerate(); + //print_r($ST); + unset($ST['closed']); + unset($ST[$this->issue->status]); + + $this->change_status = empty($ST) ? array() : array_keys($ST); + + $ac = MTrackAuth::getUserClass($this->authUser->userid); + //var_dump($ac);exit; +// KLUDGE! - remove later... + if ($ac == 'admin') { + + $this->resolve_status= array('fixed'); + $R = new MTrackResolution; + $resolutions = $R->enumerate(); + unset($resolutions['fixed']); + + $this->resolve_status= array_keys($resolutions); + array_unshift($this->resolve_status, 'fixed'); + // $html .= $this->mtrack_chg_status('action', 'resolve', 'Resolve as:', 'resolution', $resolutions, $this->issue ); + } + + // } else { + // $html .= mtrack_radio('action', 'reopen', $_POST['action']); + // $html .= "
    \n"; + // } + + } + $this->elements = HTML_Template_Flexy_Factory::fromArray($this->issue->toArray(), $this->elements); + if (!empty($_POST)) { + $this->elements = HTML_Template_Flexy_Factory::fromArray($_POST, $this->elements); + } + + + + + } + + function rights() { + if ($this->id == 'new' || empty($this->id)) { + MTrackACL::requireAllRights("Tickets", 'create'); + $this->editable = MTrackACL::hasAllRights("Tickets", 'create'); + return; + } + + MTrackACL::requireAllRights("ticket:" . $this->issue->tid, 'read'); + $this->editable = MTrackACL::hasAllRights("ticket:" . $this->issue->tid, 'modify'); + + } + + function captcha() + { + return MTrack_Captcha::emit('ticket'); + } + + + function eq($a,$b) { + return $a == $b; + } + +} \ No newline at end of file diff --git a/MTrackWeb/Timeline.php b/MTrackWeb/Timeline.php new file mode 100644 index 00000000..10e9cb04 --- /dev/null +++ b/MTrackWeb/Timeline.php @@ -0,0 +1,180 @@ +start_time)) { + $date_limit = strtotime($this->start_time); + } else { + $date_limit = $this->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)) { FIXME + $filter_users = array(mtrack_canon_username($only_users)); + } else if (is_array($only_users)) { // will not happen.... + $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(); + + // check commits. + 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, + ); + } + } + } + // look in changes + 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, function ($a, $b) { + return strcmp($b['changedate'], $a['changedate']); + }); + $this->events = []; + $last_date = false; + foreach($events as $e) { + $d = date_create($e['changedate'], new DateTimeZone('UTC')); + date_timezone_set($d, new DateTimeZone(date_default_timezone_get())); + $e['time'] = $d->format('H:i'); + $day = $d->format('D, M d Y'); + if ($last_date != $day) { + $this->events[] = (object) array( + 'day' => $day, + 'isDay' => 1; + ); + + $last_date = $day; + } + + $this->events[] = (object)$e; + + + } + + + } + + function 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; + } + +} + diff --git a/MTrackWeb/Tree.php b/MTrackWeb/Tree.php new file mode 100644 index 00000000..2465a814 --- /dev/null +++ b/MTrackWeb/Tree.php @@ -0,0 +1,355 @@ +pi = '/'. $pi . (strlen($pi) ? $this->bootLoader->ext : ''); + + //var_dump($pi); + $crumbs = MTrackSCM::makeBreadcrumbs($this->pi); // i think this modifieds pi... naughty really. + + // print_R($this->pi); + //var_dump($this->pi); + if (!strlen($this->pi) || $this->pi == '/') { + $this->pi = '/'; + } + + $this->repo = (count($crumbs) > 2) ? MTrackSCM::factory($this->pi) : null ; + + + $this->object = null; + $this->ident = null; + + if (isset($_GET['jump']) && strlen($_GET['jump'])) { + list($this->object, $this->ident) = explode(':', $_GET['jump'], 2); + } + + + $this->bdata = $this->getBrowseData($this->repo, $this->pi, $this->object, $this->ident); + + //$this->bdata = mtrack_cache( + // array($this,'getBrowseData'), + // array($this->repo, $this->pi, $this->object, $this->ident) + //); + + if (isset($this->bdata->err) && strlen($this->pi) > 1) { + throw new Exception($this->bdata->err); + } + + + $this->crumbs = array(); + $location = null; + // array_unshift($crumbs, ''); + foreach($crumbs as $path) + { + if (count($this->crumbs) && !strlen($path)) { + continue; + } + $c = new StdClass; + $c->name = strlen($path) ? $path : '[root]'; + $location .= strlen($location) ? '/' : ''; + $location .= strlen($path) ? urlencode($path) : ''; + $c->location = $location; + $this->crumbs[] = $c; + } + + + if (count($this->bdata->jumps)) { + require_once 'HTML/Template/Flexy/Element.php'; + $this->elements['jump'] = new HTML_Template_Flexy_Element('select'); + // print_r($bdata->jumps); + $this->elements['jump']->setOptions($this->bdata->jumps); + } + + + + + + 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:{$this->authUser->userid}" => $this->authUser->userid); + $q = MTrackDB::q( 'select projid, shortname, name from projects order by ordinal') ; + foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (MTrackACL::hasAllRights("project:". $row['projid'], 'modify')) { + $owners["project:". $row['shortname']] = $row['shortname']; + } + } + $this->showCreate = count($owners) > 1 ? true : false; + require_once 'HTML/Template/Flexy/Element.php'; + $this->elements['repo:parent'] = new HTML_Template_Flexy_Element('select'); + $this->elements['repo:parent']->setOptions($owners); + } + + if ($this->repo) { + MTrackACL::requireAllRights("repo:{$this->repo->repoid}", 'read'); + + // this looks buggy.. + + if ( $this->repo->canFork() && + MTrackACL::hasAllRights('Browser', 'fork') && + MTrackConfig::get('repos', 'allow_user_repo_creation') + ) { + + $this->canFork = true; + $this->forkname = $this->repo->shortname; + + if ("{$this->authUser->userid}/{$this->repo->shortname}" == $this->repo->getBrowseRootName()) { + /* if this is mine already, make a "more unique" name for my new fork */ + $this->forkname = $this->repo->shortname . '2'; + } + } + + if ( $this->repo->parent && + MTrackACL::hasAllRights("repo:{$this->repo->repoid}", "delete") + ) { + + $this->canDeleteFork = true; + } + + if (MTrackACL::hasAllRights("repo:{$this->repo->repoid}", "modify")) { + $this->canEditRepo = true; + } + // watch UI is different in this version.. + // MTrackWatch::renderWatchUI('repo', $repo->repoid); + + } + + /// non repo options.. + + + if (!$this->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']; + } + require_once 'HTML/Template/Flexy/Element.php'; + $this->elements['repo:type'] = new HTML_Template_Flexy_Element('select'); + $this->elements['repo:type']->setOptions($repotypes); + } + + + // up... + if (count($this->crumbs) > 1) { + $this->dirname = $this->repo ? $this->repo->displayName() : ''; + if (strlen($this->pi)) { + $this->dirname .='/'. $this->pi; + } + $this->up = dirname($pi); + $this->jump = isset($_GET['jump']) ? $_GET['jump'] : ''; + } + + + + + + if (!$this->repo) { + $mine = 'user:' . $this->authUser->userid; + + $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; + + $q = MTrackDB::get()->prepare(" + SELECT repoid + FROM repos + WHERE $where + ORDER BY + CASE WHEN parent = ? THEN 0 ELSE 1 END, + shortname + "); + $q->execute($params); + $this->repos = array(); + foreach ($q->fetchAll(PDO::FETCH_OBJ) as $rep) { + if (!MTrackACL::hasAnyRights("repo:{$rep->repoid}", 'read')) { + continue; + } + + $this->repos[]= MTrackRepo::loadById($rep->repoid); + } + } + + //print_r($this); exit; + + + } + + + + + + + + +// static..? + function getBrowseData($repo, $pi, $object, $ident) + { + + $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(); + } + // echo '
    ' ; var_dump($ents); echo '
    ' ; + 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 = $this->baseURL . '/browse.php'; + $pathbase = '/' . $repo->getBrowseRootName(); + + foreach ($dirs as $basename => $file) { + + $ent = $file->getChangeEvent(); //MTrackSCMEvent + // let's copy extra stuff into the event.. + $ent->url = $urlbase . $pathbase . '/' . $file->name; + $ent->basename = $basename; + $ent->changelogOne = $ent->changelogOneToHtml(); + + $data->dirs[] = $ent; + } + + foreach ($files as $basename => $file) { + $ent = $file->getChangeEvent(); + if (!$ent) { // skips broken files.. + continue; + } + // needed? + $ent->basename = $basename; + + $data->files[] = $ent; + } + + return $data; + } + + + +} + + + + + +// read the data for the page.. +//$bdata = get_browse_data($repo, $pi, $object, $ident); + +//print_R($bdata); + + +/* Render a bread-crumb enabled location indicator */ + + + + + + + + + + + + + + + + + diff --git a/MTrackWeb/Watch.php b/MTrackWeb/Watch.php new file mode 100644 index 00000000..82e75ea9 --- /dev/null +++ b/MTrackWeb/Watch.php @@ -0,0 +1,95 @@ +objname = empty($_REQUEST['objname']) ? '' : $_REQUEST['objname']; + $this->objid = empty($_REQUEST['objid']) ? '' : $_REQUEST['objid']; + + + // rights.. + + MTrackACL::requireAllRights( $this->objname.':'.$this->objid, 'read'); + + // list.. + $this->watchers = MTrackWatch::objectWatchersNameId( $this->objname, $this->objid); + + + $users = MTrack_DataObjects_Userinfo::selectList(array(''=>'-- Select to add --')); + $this->selfsubscribe = true; + foreach($this->watchers as $w) { + if (isset($users[$w->userid])) { + unset($users[$w->userid]); + } + if ($this->authUser->userid == $w->userid) { + $this->selfsubscribe = false; + } + } + require_once 'HTML/Template/Flexy/Element.php'; + $this->elements['subscribe-add'] = new HTML_Template_Flexy_Element('select'); + $this->elements['subscribe-add']->setOptions($users); + $this->addsubscribe = true; + if (count(array_keys($users)) == 1) { + $this->addsubscribe = false; + } + // never inherit.. + $this->elements['subscribe-add']->setValue(''); + + //$this->renderEvents(); + + } + + function post() + { + + $this->objname = empty($_REQUEST['objname']) ? '' : $_REQUEST['objname']; + $this->objid = empty($_REQUEST['objid']) ? '' : $_REQUEST['objid']; + + if (empty($_REQUEST['userid'])) { + die("INVALID USER ID"); + } + + require_once 'DataObjects/Userinfo.php'; + + // throws exception if fails.. + MTrack_DataObjects_Userinfo::get($_REQUEST['userid']); + // echo "Trying to add..."; + MTrackWatch::watch_object( $this->objname, $this->objid, $_REQUEST['userid']); + + return $this->get(); + // carry on and show get(.. + } + +} \ No newline at end of file diff --git a/MTrackWeb/Wiki.php b/MTrackWeb/Wiki.php new file mode 100644 index 00000000..2beac6d4 --- /dev/null +++ b/MTrackWeb/Wiki.php @@ -0,0 +1,341 @@ +pi = empty($pi) ? 'WikiStart' : ($pi . $this->bootLoader->ext); + + + $this->edit = isset($_REQUEST['edit']) ? (int)$_REQUEST['edit'] : false; + + + $this->hasHistory = false; + $this->doc = MTrack_Wiki_Item::loadByPageName($this->pi); + if ($this->doc) { + MTrackACL::requireAnyRights("wiki:{$this->doc->pagename}", $this->edit ? 'modify' : 'read'); + $this->hasHistory = true; + } else { + MTrackACL::requireAnyRights("wiki:$this->pi", $this->edit ? 'modify' : 'read'); + } + // blank doc.. on edit.. + if ($this->doc === null && $this->edit) { + $this->doc = new MTrack_Wiki_Item($this->pi); + $this->doc->content = " = {$this->pi} =\n"; + } + + + /* now just render */ + + $this->title = $this->pi; + if ($this->edit) { + $this->title .= " (edit)"; + } + $this->canEdit = !$this->edit && MTrackACL::hasAnyRights("wiki:{$this->pi}", 'modify'); + + + if ($this->doc && $this->doc->file) { + $this->evt = $this->doc->file->getChangeEvent(); + if (!strlen($this->evt->changelog)) { + $this->evt->changelog = 'Changed'; + } + } + + if (!$this->edit && !$this->hasHistory) { + if (MTrackACL::hasAnyRights("wiki:{$this->pi}", 'create')) { + $this->canCreate = 1; + } else { + $this->notExist = 1; + } + } + if ($this->edit) { + if (isset($_POST['preview'])) { + $this->showPreview = true; + $this->preview = MTrackWiki::format_to_html($content); + } + $this->doc->contentb64= base64_encode($this->doc->content); + $this->doc->content = isset($_POST['content']) ? $_POST['content'] : $this->doc->content; + $this->comment = isset($_POST['comment']) ? $_POST['comment'] : ''; + + } + + $action = isset($_GET['action']) ? $_GET['action'] : 'view'; + + switch ($action) { + case 'view': + $this->actionView = 1; + break; + + case 'list': + $this->actionList = 1; + + $htree = array(); + $this->build_help_tree($htree, dirname(__FILE__) . '/../defaults/help'); + $this->helptree = $htree; + + + /* get the page names into a tree format */ + $tree = array(); + $repo = false; // + $root = MTrack_Wiki_Item::getRepoAndRoot($repo); + $suf = MTrackConfig::get('core', 'wikifilenamesuffix'); // fixme!!!! + build_tree($tree, $repo, $root, $suf); + $this->tree = $tree; + + break; + + case 'recent': + $this->actionRecent = 1; + $root = MTrack_Wiki_Item::getRepoAndRoot($repo); + $this->recent = $repo->history(null, 100) ; + foreach ($this->recent as $e) { + list($e->page) = $e->files; + if (strlen($root)) { + $e->page = substr($e->page, strlen($root)+1); + } + } + + break; + + } + } + + 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"; + } + + function post() + { + $this->get(); + if (isset($_POST['cancel'])) { + header("Location: {$this->baseURL}/Wiki/{$this->pi}"); + exit; + } + + + if (!MTrackCaptcha::check('wiki')) { + $this->message = 'CAPTCHA failed, please try again'; + return; + } + + /* 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); + $this->doc->content = normalize_text($this->doc->content); + $this->conflicted = is_content_conflicted($content); + $tempdir = sys_get_temp_dir(); + + if (!$this->conflicted) { + $ofile = tempnam($tempdir, "mtrack"); + $nfile = tempnam($tempdir, "mtrack"); + $tfile = tempnam($tempdir, "mtrack"); + $pfile = tempnam($tempdir, "mtrack"); + + require_once 'System.php'; + $diff3 = System::which('diff3'); + if (empty($diff3)) { + $diff3 = 'diff3'; + } + + file_put_contents($ofile, $orig); + file_put_contents($nfile, $content); + file_put_contents($tfile, $this->doc->content); + + if (PHP_OS == 'SunOS') { // seriously does anyone use SunOS anymore??? + 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); + + $this->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($this->doc->content); + + if ($this->conflicted) { + $this->message = "Conflicting edits were detected; please correct them before saving"; + } else { + $this->doc->content = $content; + try { + $cs = MTrackChangeset::begin("wiki:{$this->pi}", $_POST['comment']); + $this->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: + $this->message = "Attachment(s) exceed the upload file size limit"; + break; + case UPLOAD_ERR_PARTIAL: + case UPLOAD_ERR_CANT_WRITE: + $this->message = "Attachment file upload failed"; + break; + case UPLOAD_ERR_NO_TMP_DIR: + $this->message = "Server configuration prevents upload due to missing temporary dir"; + break; + case UPLOAD_ERR_EXTENSION: + $this->message = "An extension prevented an upload from running"; + } + if ($this->message !== null) { + throw new Exception($this->message); + } + if ($do_attach) { + MTrackAttachment::add("wiki:{$this->pi}", + $_FILES['attachments']['tmp_name'][$fileid], + $_FILES['attachments']['name'][$fileid], + $cs); + } + } + } + MTrackAttachment::process_delete("wiki:{$this->pi}", $cs); + $cs->commit(); + MTrack_Wiki_Item::commitNow(); + $saved = true; + } catch (Exception $e) { + $this->message = $e->getMessage(); + } + } + + if ($saved) { + /* we're good; go back to view mode */ + header("Location: {$this->baseURL}/Wiki/{$this->pi}"); + exit; + } + } + + 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(); + $this->build_help_tree($kid, $full); + $tree[$ent] = $kid; + } else { + $tree[$ent] = array(); + } + } + } + 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(); + $this->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(); + } + } + } + function emit_tree($root, $phppage,$parent='') + { + + if (strlen($parent)) { + echo "
      \n"; + } else { + echo "
        \n"; + } + $knames = array_keys($root); + usort($knames, 'strnatcasecmp'); + foreach ($knames as $key) { + $kids = $root[$key]; + $n = htmlentities($key, ENT_QUOTES, 'utf-8'); + echo "
      • "; + if (count($kids)) { + echo $n; + emit_tree($kids, $phppage,"$parent$key/"); + } else { + echo "baseURL}/$phppage/$parent$n\">$n"; + } + echo "
      • \n"; + } + echo "
      \n"; + } + + + function renderDeleteList() { + return MTrackAttachment::renderDeleteList("wiki:{$this->pi}"); + } + function renderList() { + return MTrackAttachment::renderList("wiki:{$this->pi}"); + } + + + function captcha(){ + return MTrackCaptcha::emit('wiki'); + } + } \ No newline at end of file diff --git a/MTrackWeb/templates/admin.html b/MTrackWeb/templates/admin.html new file mode 100644 index 00000000..83a3ed71 --- /dev/null +++ b/MTrackWeb/templates/admin.html @@ -0,0 +1,43 @@ + +
      + Projects and their notification settings + + +
      + +
      +

      Configure Tickets

      + Priority, + TicketState, + Severity, + Resolution, + Classification fields used in tickets + Custom Fields +
      + +
      +

      Configure Tickets

      +

      Configure Projects & Notifications

      + Components and their associations with Projects +
      + +
      +

      Configure Tickets

      + Import Tickets from a CSV file +
      + +
      +

      Configure Repositories

      + Configure Repositories and their links to Projects +
      + +
      +

      User Administration & Authentication

      + Administer Authentication + Administer Users +
      + +
      +

      Review Logs

      + Indexer logs +
      \ No newline at end of file diff --git a/MTrackWeb/templates/browse.html b/MTrackWeb/templates/browse.html new file mode 100644 index 00000000..060d5422 --- /dev/null +++ b/MTrackWeb/templates/browse.html @@ -0,0 +1,27 @@ + + + + + + +
      +

      Loading browse data, please wait

      +
      + + + diff --git a/MTrackWeb/templates/changeset.html b/MTrackWeb/templates/changeset.html new file mode 100644 index 00000000..976486f9 --- /dev/null +++ b/MTrackWeb/templates/changeset.html @@ -0,0 +1,70 @@ + +
      Revision: {repo.shortname} {ent.rev} + {foreach:ent.branches,b} {link.branch(b):h} {end:} + {foreach:ent.tags,b} {link.tags(b):h} {end:} +
      + {data.changelogToHtml()} + + + +
      + {link.username(ent.changeby,#size=32#):h}
      + {link.date(ent.ctime):h} + + {if:rdata.parents} + Prior: + {foreach:rdata.parents,p} + {link.changeset(p,repo):h} + {end:} + {end:} + + {if:rdata.kids} + Next: + {foreach:rdata.kids,p} + {link.changeset(p,repo):h} + {end:} + {end:} +
      +
      + + +
      +

      Download diff +
      +

      Affected files:

      + +
      + +
      + + \ No newline at end of file diff --git a/MTrackWeb/templates/file.html b/MTrackWeb/templates/file.html new file mode 100644 index 00000000..67e88e42 --- /dev/null +++ b/MTrackWeb/templates/file.html @@ -0,0 +1,97 @@ + + + + +
      Location: + {foreach:crumbs,p} + / {p.name} + {end:} + + / {basename} @ {ent.changesetToHtml(link)} +
      + + + + +
      + +
      + {ent.changebyToHtml(link):h} + + +
      + {ent.ctimeToHtml(link):h} +
      + +
      {ent.changelogToHtml():h}
      + +
      + + +
      + + + +
      + +Revision: {repo.shortname} {ent.rev} + + +{b} + + +{foreach:ent.tags,t} + mmtrack_tag($t) + {t:r} + +{end:} + + + - Show revision log + +
      Deleted
      + +
      +
      + + + +{schemeSelect():h} + +

      + + + + + + + + + + + + + + + + + + + + + +
      revwholinecode
      {l.revToHtml(link):h}{l.changebyToHtml(link):h}{l.lineno}{data:h}
      + +

      +
      + +

      +Download File ({mimetype}) + + + + + + + + \ No newline at end of file diff --git a/MTrackWeb/templates/help.html b/MTrackWeb/templates/help.html new file mode 100644 index 00000000..c2c2576b --- /dev/null +++ b/MTrackWeb/templates/help.html @@ -0,0 +1,16 @@ + + +
      +

      Help topics

      + +
      + + +

      No Help topic {no_topic}

      + +{body:h} + + + diff --git a/MTrackWeb/templates/images/js/mtrack.file.event.js b/MTrackWeb/templates/images/js/mtrack.file.event.js new file mode 100644 index 00000000..563608f9 --- /dev/null +++ b/MTrackWeb/templates/images/js/mtrack.file.event.js @@ -0,0 +1,65 @@ +// + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + + Authentication is not yet configured. Use the command line to + create an admin at present +
      +
      + {outputBody():h} + +
      + + + + + + + + diff --git a/MTrackWeb/templates/preview.html b/MTrackWeb/templates/preview.html new file mode 100644 index 00000000..85349c5d --- /dev/null +++ b/MTrackWeb/templates/preview.html @@ -0,0 +1 @@ +{body:h} \ No newline at end of file diff --git a/MTrackWeb/templates/report.html b/MTrackWeb/templates/report.html new file mode 100644 index 00000000..bbb692f8 --- /dev/null +++ b/MTrackWeb/templates/report.html @@ -0,0 +1,49 @@ + + + +
      {message}
      + + + + +

      {rep.summary}

      +{rep.descriptionToHtml():h} +{rep.render():h} + + +{if:edit} +
      + +
      + + + [{rep.rid}] + +
      +
      + + +
      + + +
      + Reason for change: + + + +
      + + +{else:} +
      + + +
      +{end:} + \ No newline at end of file diff --git a/MTrackWeb/templates/reports.html b/MTrackWeb/templates/reports.html new file mode 100644 index 00000000..1c6dae20 --- /dev/null +++ b/MTrackWeb/templates/reports.html @@ -0,0 +1,23 @@ + +

      Available Reports

      + +

      + The reports below are constructed using SQL. You may also + use the Custom Query + page to create a report on the fly. +

      + + + + + + + + +
      ReportTitle
      {row.id} + {row.summary} +
      + +
      + +
      diff --git a/MTrackWeb/templates/ticket.html b/MTrackWeb/templates/ticket.html new file mode 100644 index 00000000..fc7f972f --- /dev/null +++ b/MTrackWeb/templates/ticket.html @@ -0,0 +1,305 @@ + + + + + +
      + + This is a preview of your pending changes. It does not show + changes to the resolution; those will be applied when you submit. +
      + + + +
      + + {e} +
      + +
      + + + +
      + +

      + #{issue.nsident} [{issue.status}] {issue.summary} + #{issue.nsident} [{issue.status}] {issue.summary} +

      + + + + + + +
      + + + + + + + + + + + + +
      :{issue.createdWhen(link):h}{issue.createdWho(link):h}
      :{issue.updatedWhen(link):h}{issue.updatedWho(link):h}
      + + + + + + +
      + Properties + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      :{issue.milestoneToHtml():h}
      :{issue.componentsToHtml():h}
      :{issue.classification}
      :{issue.priority}
      :{issue.severity}
      :{issue.keywordsToHtml():h}
      :
      {issue.changelog:b}
      +
      +
      + Resources + + + + + + + + + + + + + + + + + + + + +
      :{issue.owner}
      :{issue.estimated}
      :{issue.spent}
      :{issue.cc}
      +
      + +
      + + {attachmentsToHtml(issue):h} + +
      {issue.descriptionToHtml():h}
      +
      + +
      + +
      + + + +
      + + + + + +
      + + + + + + + + + +
      + +
      + +
      +
      + +
      + + + + + + + + \ No newline at end of file diff --git a/MTrackWeb/templates/timeline.html b/MTrackWeb/templates/timeline.html new file mode 100644 index 00000000..38e144cb --- /dev/null +++ b/MTrackWeb/templates/timeline.html @@ -0,0 +1,88 @@ + +
        + {foreach:events,row} + +
      • {ent.day}
      • +
      • +
        {link.username(ent.who,#no_name=1,size=48,class=timelineface#)} +
        +
        + $reason +
        + {ent.time} $item by {link.username(ent.who,#no_image=1#)} +
        +
        + + // 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']; + } + $item = $row['object']; + switch ($object) { + case 'ticket': + if (!strncmp($row['reason'], 'created ', 8)) { + } elseif (!strncmp($row['reason'], 'closed ', 7)) { + } else { + } + $item = "Ticket " . mtrack_ticket($id); + break; + case 'wiki': + $item = "Wiki " . mtrack_wiki_link($id); + break; + case 'milestone': + $item = "Milestone $id"; + break; + case 'changeset': + preg_match("/^changeset:(.*):([^:]+)$/", $row['object'], $M); + $repo = $M[1]; + if (!$this->is_repo_visible($repo)) { + continue 2; + } + $id = $M[2]; + $item = "$repo change " . mtrack_changeset($id, $repo); + break; + case 'snippet': + $item = "View Snippet"; + 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 (!$this->is_repo_visible($id)) { + continue 2; + } + if (isset($repos[$id])) { + $name = MTrackRepo::makeDisplayName($repos[$id]); + $item = "$name"; + } else { + $item = "<item has been deleted>"; + } + break; + } + + $reason = MTrack_Wiki::format_to_oneliner($row['reason']); + + echo "
        ", + mtrack_username($row['who'], array( + 'no_name' => true, + 'size' => 48, + 'class' => 'timelineface' + )), + "
        ", + "
        ", + "$reason
        \n", + "$time $item by ", + mtrack_username($row['who'], array('no_image' => true)), + "
        \n"; + echo "
        \n"; + } + echo "
      \n"; \ No newline at end of file diff --git a/MTrackWeb/templates/tree.html b/MTrackWeb/templates/tree.html new file mode 100644 index 00000000..4bba846c --- /dev/null +++ b/MTrackWeb/templates/tree.html @@ -0,0 +1,244 @@ + + +
      Location: +{foreach:crumbs,p} + / {p.name} +{end:} + +
      + + +
      + + + +
      {repo.descriptionToHtml():h}
      +
      + Use the following command to obtain a working copy:
      +
      $ {repo.getCheckoutCommand()}
      +
      + + + + + + Edit + +
      + Show History +
      +
      + + + + +
      + + + + + + + + + + + + + + + + + + + + + + + +
      NameDescription
      .. [up]
      {rep.displayName()}{rep.descriptionToHtml():h}
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      NameRevisionAgeLast Change
      .. [up]
      {d.basename}{d.changeset(link):h}{d.ctimeToHtml(link):h}{d.changeByToHtml(link):h}: {d.changelogOneToHtml():h}
      {d.basename}{d.changeset(link):h}{d.ctimeToHtml(link):h}{d.changeByToHtml(link):h}: {d.changelogOneToHtml():h}
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MTrackWeb/templates/watch.html b/MTrackWeb/templates/watch.html new file mode 100644 index 00000000..8acb51e1 --- /dev/null +++ b/MTrackWeb/templates/watch.html @@ -0,0 +1,30 @@ + +
      + +
      + + + + + +
      +

      Subscribe someone else

      + +
      +
      + +
      +

      Subscribers

      + + +
      +
      \ No newline at end of file diff --git a/MTrackWeb/templates/wiki.html b/MTrackWeb/templates/wiki.html new file mode 100644 index 00000000..6d201639 --- /dev/null +++ b/MTrackWeb/templates/wiki.html @@ -0,0 +1,124 @@ +
      + {link.username(evt.changeby,#no_name=1,class=wikilastchange#):h} + {evt.changelog} by + {link.username(evt.changeby,#no_image=1#):h} + {link.date(evt.ctime):h} +
      + + + + +
      + + +
      + + {message} +
      + + +
      + Wiki page $ppi doesn't exist, would you like to create it?
      "; + +
      + + +
      +
      + +
      + Wiki page {pi} doesn't exist. +
      +
      + + +
      +

      Editing {pi}

      + Wiki Formatting (opens in a new window)
      + +
      {preview:h}
      + +
      + + + + + + +
      + Attachments + {renderDeleteList():h} + + + +
      + +
      + Change Information +
      + +
      + {captcha():h} +
      + +
      + + + +
      +
      +
      + + +
      + {doc.toHtml():h} + + {renderList():h} + +
      + + +
      + +
      + + +
      +

      Help topics by Title

      + {emit_tree(helptree,#Help#):h} + +

      Wiki pages by Title

      + {emit_tree(tree,#Wiki#):h} + +
      + + + +
      + +

      Recently Edited Wiki Pages

      + + + + + + + + + + + + + +
      PageDateWhoReason
      {r.page}{link.date(r.ctime):h}{link.username(r.changeby):h}{r.changelog}
      +
      + diff --git a/Zend/Exception.php b/Zend/Exception.php new file mode 100644 index 00000000..3cb5704b --- /dev/null +++ b/Zend/Exception.php @@ -0,0 +1,31 @@ +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/Zend/Search/Lucene/Analysis/Analyzer.php b/Zend/Search/Lucene/Analysis/Analyzer.php new file mode 100644 index 00000000..171f2b20 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer.php @@ -0,0 +1,177 @@ +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/Zend/Search/Lucene/Analysis/Analyzer/Common.php b/Zend/Search/Lucene/Analysis/Analyzer/Common.php new file mode 100644 index 00000000..de63cdbd --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common.php @@ -0,0 +1,81 @@ +_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/Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php new file mode 100644 index 00000000..a9bf3d06 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/Text.php @@ -0,0 +1,96 @@ +_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/Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php new file mode 100644 index 00000000..3267b4b9 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/Text/CaseInsensitive.php @@ -0,0 +1,47 @@ +addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCase()); + } +} + diff --git a/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php new file mode 100644 index 00000000..b2e99de9 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum.php @@ -0,0 +1,95 @@ +_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/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php new file mode 100644 index 00000000..f37fa44a --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/TextNum/CaseInsensitive.php @@ -0,0 +1,47 @@ +addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCase()); + } +} + diff --git a/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php new file mode 100644 index 00000000..0a8237f9 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8.php @@ -0,0 +1,126 @@ +_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/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php new file mode 100644 index 00000000..dfc86510 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8/CaseInsensitive.php @@ -0,0 +1,49 @@ +addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCaseUtf8()); + } +} + diff --git a/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php new file mode 100644 index 00000000..b39cc401 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num.php @@ -0,0 +1,126 @@ +_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/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php new file mode 100644 index 00000000..092a0c66 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Analyzer/Common/Utf8Num/CaseInsensitive.php @@ -0,0 +1,49 @@ +addFilter(new Zend_Search_Lucene_Analysis_TokenFilter_LowerCaseUtf8()); + } +} + diff --git a/Zend/Search/Lucene/Analysis/Token.php b/Zend/Search/Lucene/Analysis/Token.php new file mode 100644 index 00000000..dbe3da08 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/Token.php @@ -0,0 +1,154 @@ +_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/Zend/Search/Lucene/Analysis/TokenFilter.php b/Zend/Search/Lucene/Analysis/TokenFilter.php new file mode 100644 index 00000000..895b4007 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/TokenFilter.php @@ -0,0 +1,48 @@ +getTermText() ), + $srcToken->getStartOffset(), + $srcToken->getEndOffset()); + + $newToken->setPositionIncrement($srcToken->getPositionIncrement()); + + return $newToken; + } +} + diff --git a/Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php b/Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php new file mode 100644 index 00000000..7f9bbb27 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/TokenFilter/LowerCaseUtf8.php @@ -0,0 +1,70 @@ +getTermText(), 'UTF-8'), + $srcToken->getStartOffset(), + $srcToken->getEndOffset()); + + $newToken->setPositionIncrement($srcToken->getPositionIncrement()); + + return $newToken; + } +} + diff --git a/Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php b/Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php new file mode 100644 index 00000000..04e2d489 --- /dev/null +++ b/Zend/Search/Lucene/Analysis/TokenFilter/ShortWords.php @@ -0,0 +1,69 @@ +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/Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php b/Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php new file mode 100644 index 00000000..50379c3c --- /dev/null +++ b/Zend/Search/Lucene/Analysis/TokenFilter/StopWords.php @@ -0,0 +1,101 @@ + 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/Zend/Search/Lucene/Document.php b/Zend/Search/Lucene/Document.php new file mode 100644 index 00000000..499d9b43 --- /dev/null +++ b/Zend/Search/Lucene/Document.php @@ -0,0 +1,131 @@ +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/Zend/Search/Lucene/Document/Docx.php b/Zend/Search/Lucene/Document/Docx.php new file mode 100644 index 00000000..19de6dfd --- /dev/null +++ b/Zend/Search/Lucene/Document/Docx.php @@ -0,0 +1,144 @@ +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/Zend/Search/Lucene/Document/Exception.php b/Zend/Search/Lucene/Document/Exception.php new file mode 100644 index 00000000..bb9a07db --- /dev/null +++ b/Zend/Search/Lucene/Document/Exception.php @@ -0,0 +1,37 @@ +_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('//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)) + . '' + . 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('' + . iconv($defaultEncoding, 'UTF-8//IGNORE', $htmlData) + . ''); + } + + } + /** @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('' + . $highlightedWordNodeSetHtml + . ''); + 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 '' . $stringToHighlight . ''; + } + + /** + * 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/Zend/Search/Lucene/Document/OpenXml.php b/Zend/Search/Lucene/Document/OpenXml.php new file mode 100644 index 00000000..96492a23 --- /dev/null +++ b/Zend/Search/Lucene/Document/OpenXml.php @@ -0,0 +1,132 @@ +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/Zend/Search/Lucene/Document/Pptx.php b/Zend/Search/Lucene/Document/Pptx.php new file mode 100644 index 00000000..66251705 --- /dev/null +++ b/Zend/Search/Lucene/Document/Pptx.php @@ -0,0 +1,193 @@ +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/Zend/Search/Lucene/Document/Xlsx.php b/Zend/Search/Lucene/Document/Xlsx.php new file mode 100644 index 00000000..bcdda574 --- /dev/null +++ b/Zend/Search/Lucene/Document/Xlsx.php @@ -0,0 +1,256 @@ +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/Zend/Search/Lucene/Exception.php b/Zend/Search/Lucene/Exception.php new file mode 100644 index 00000000..33ce4571 --- /dev/null +++ b/Zend/Search/Lucene/Exception.php @@ -0,0 +1,37 @@ + 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/Zend/Search/Lucene/FSMAction.php b/Zend/Search/Lucene/FSMAction.php new file mode 100644 index 00000000..bb6007e0 --- /dev/null +++ b/Zend/Search/Lucene/FSMAction.php @@ -0,0 +1,66 @@ +_object = $object; + $this->_method = $method; + } + + public function doAction() + { + $methodName = $this->_method; + $this->_object->$methodName(); + } +} + diff --git a/Zend/Search/Lucene/Field.php b/Zend/Search/Lucene/Field.php new file mode 100644 index 00000000..c8465d3d --- /dev/null +++ b/Zend/Search/Lucene/Field.php @@ -0,0 +1,226 @@ +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/Zend/Search/Lucene/Index/DictionaryLoader.php b/Zend/Search/Lucene/Index/DictionaryLoader.php new file mode 100644 index 00000000..bc1a41ca --- /dev/null +++ b/Zend/Search/Lucene/Index/DictionaryLoader.php @@ -0,0 +1,268 @@ +.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/Zend/Search/Lucene/Index/DocsFilter.php b/Zend/Search/Lucene/Index/DocsFilter.php new file mode 100644 index 00000000..b531bb8c --- /dev/null +++ b/Zend/Search/Lucene/Index/DocsFilter.php @@ -0,0 +1,59 @@ + => array( => , + * => , + * => , + * ... ), + * => array( => , + * => , + * => , + * ... ), + * => array( => , + * => , + * => , + * ... ), + * ... + * ) + * + * @var array + */ + public $segmentFilters = array(); +} + diff --git a/Zend/Search/Lucene/Index/FieldInfo.php b/Zend/Search/Lucene/Index/FieldInfo.php new file mode 100644 index 00000000..a3b9d2e7 --- /dev/null +++ b/Zend/Search/Lucene/Index/FieldInfo.php @@ -0,0 +1,50 @@ +name = $name; + $this->isIndexed = $isIndexed; + $this->number = $number; + $this->storeTermVector = $storeTermVector; + $this->normsOmitted = $normsOmitted; + $this->payloadsStored = $payloadsStored; + } +} + diff --git a/Zend/Search/Lucene/Index/SegmentInfo.php b/Zend/Search/Lucene/Index/SegmentInfo.php new file mode 100644 index 00000000..c4c05b25 --- /dev/null +++ b/Zend/Search/Lucene/Index/SegmentInfo.php @@ -0,0 +1,2117 @@ + $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 .del file name + $delFileList[] = 0; + } else if (preg_match('/^' . $this->_name . '_([a-zA-Z0-9]+)\.del$/i', $file, $matches)) { + // Matches _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/Zend/Search/Lucene/Index/SegmentMerger.php b/Zend/Search/Lucene/Index/SegmentMerger.php new file mode 100644 index 00000000..5afdced1 --- /dev/null +++ b/Zend/Search/Lucene/Index/SegmentMerger.php @@ -0,0 +1,271 @@ +][] => + * + * @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/Zend/Search/Lucene/Index/SegmentWriter.php b/Zend/Search/Lucene/Index/SegmentWriter.php new file mode 100644 index 00000000..63cd4ea6 --- /dev/null +++ b/Zend/Search/Lucene/Index/SegmentWriter.php @@ -0,0 +1,627 @@ + 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/Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php b/Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php new file mode 100644 index 00000000..1e9f8855 --- /dev/null +++ b/Zend/Search/Lucene/Index/SegmentWriter/DocumentWriter.php @@ -0,0 +1,214 @@ +_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/Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php b/Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php new file mode 100644 index 00000000..b6b2ca80 --- /dev/null +++ b/Zend/Search/Lucene/Index/SegmentWriter/StreamWriter.php @@ -0,0 +1,94 @@ +_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/Zend/Search/Lucene/Index/Term.php b/Zend/Search/Lucene/Index/Term.php new file mode 100644 index 00000000..a042cfd8 --- /dev/null +++ b/Zend/Search/Lucene/Index/Term.php @@ -0,0 +1,144 @@ +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/Zend/Search/Lucene/Index/TermInfo.php b/Zend/Search/Lucene/Index/TermInfo.php new file mode 100644 index 00000000..2cd822da --- /dev/null +++ b/Zend/Search/Lucene/Index/TermInfo.php @@ -0,0 +1,80 @@ +docFreq = $docFreq; + $this->freqPointer = $freqPointer; + $this->proxPointer = $proxPointer; + $this->skipOffset = $skipOffset; + $this->indexPointer = $indexPointer; + } +} + diff --git a/Zend/Search/Lucene/Index/TermsPriorityQueue.php b/Zend/Search/Lucene/Index/TermsPriorityQueue.php new file mode 100644 index 00000000..cbe1021a --- /dev/null +++ b/Zend/Search/Lucene/Index/TermsPriorityQueue.php @@ -0,0 +1,49 @@ +currentTerm()->key(), $termsStream2->currentTerm()->key()) < 0; + } + +} diff --git a/Zend/Search/Lucene/Index/TermsStream/Interface.php b/Zend/Search/Lucene/Index/TermsStream/Interface.php new file mode 100644 index 00000000..900b34eb --- /dev/null +++ b/Zend/Search/Lucene/Index/TermsStream/Interface.php @@ -0,0 +1,66 @@ + 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 .f 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 ('.f') + // 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 ('_.del' where is '_') + // 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 ('.') + $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 , 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/Zend/Search/Lucene/Interface.php b/Zend/Search/Lucene/Interface.php new file mode 100644 index 00000000..0052b145 --- /dev/null +++ b/Zend/Search/Lucene/Interface.php @@ -0,0 +1,404 @@ + 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/Zend/Search/Lucene/LockManager.php b/Zend/Search/Lucene/LockManager.php new file mode 100644 index 00000000..c9b639d1 --- /dev/null +++ b/Zend/Search/Lucene/LockManager.php @@ -0,0 +1,236 @@ +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/Zend/Search/Lucene/MultiSearcher.php b/Zend/Search/Lucene/MultiSearcher.php new file mode 100644 index 00000000..b8c39972 --- /dev/null +++ b/Zend/Search/Lucene/MultiSearcher.php @@ -0,0 +1,963 @@ +_indices = $indices; + + foreach ($this->_indices as $index) { + if (!$index instanceof Zend_Search_Lucene_Interface) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('sub-index objects have to implement Zend_Search_Lucene_Interface.'); + } + } + } + + /** + * Add index for searching. + * + * @param Zend_Search_Lucene_Interface $index + */ + public function addIndex(Zend_Search_Lucene_Interface $index) + { + $this->_indices[] = $index; + } + + + /** + * 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) + { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception("Generation number can't be retrieved for multi-searcher"); + } + + /** + * Get segments file name + * + * @param integer $generation + * @return string + */ + public static function getSegmentFileName($generation) + { + return Zend_Search_Lucene::getSegmentFileName($generation); + } + + /** + * Get index format version + * + * @return integer + * @throws Zend_Search_Lucene_Exception + */ + public function getFormatVersion() + { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception("Format version can't be retrieved for multi-searcher"); + } + + /** + * Set index format version. + * Index is converted to this format at the nearest upfdate time + * + * @param int $formatVersion + */ + public function setFormatVersion($formatVersion) + { + foreach ($this->_indices as $index) { + $index->setFormatVersion($formatVersion); + } + } + + /** + * Returns the Zend_Search_Lucene_Storage_Directory instance for this index. + * + * @return Zend_Search_Lucene_Storage_Directory + */ + public function getDirectory() + { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception("Index directory can't be retrieved for multi-searcher"); + } + + /** + * Returns the total number of documents in this index (including deleted documents). + * + * @return integer + */ + public function count() + { + $count = 0; + + foreach ($this->_indices as $index) { + $count += $this->_indices->count(); + } + + return $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->count(); + } + + /** + * Returns the total number of non-deleted documents in this index. + * + * @return integer + */ + public function numDocs() + { + $docs = 0; + + foreach ($this->_indices as $index) { + $docs += $this->_indices->numDocs(); + } + + return $docs; + } + + /** + * 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) + { + foreach ($this->_indices as $index) { + $indexCount = $index->count(); + + if ($indexCount > $id) { + return $index->isDeleted($id); + } + + $id -= $indexCount; + } + + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Document id is out of the range.'); + } + + /** + * 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) + { + foreach ($this->_indices as $index) { + $index->setDefaultSearchField($fieldName); + } + } + + + /** + * Get default search field. + * + * Null means, that search is performed through all fields by default + * + * @return string + * @throws Zend_Search_Lucene_Exception + */ + public static function getDefaultSearchField() + { + if (count($this->_indices) == 0) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices list is empty'); + } + + $defaultSearchField = reset($this->_indices)->getDefaultSearchField(); + + foreach ($this->_indices as $index) { + if ($index->getDefaultSearchField() !== $defaultSearchField) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices have different default search field.'); + } + } + + return $defaultSearchField; + } + + /** + * Set result set limit. + * + * 0 (default) means no limit + * + * @param integer $limit + */ + public static function setResultSetLimit($limit) + { + foreach ($this->_indices as $index) { + $index->setResultSetLimit($limit); + } + } + + /** + * Set result set limit. + * + * 0 means no limit + * + * @return integer + * @throws Zend_Search_Lucene_Exception + */ + public static function getResultSetLimit() + { + if (count($this->_indices) == 0) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices list is empty'); + } + + $defaultResultSetLimit = reset($this->_indices)->getResultSetLimit(); + + foreach ($this->_indices as $index) { + if ($index->getResultSetLimit() !== $defaultResultSetLimit) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices have different default search field.'); + } + } + + return $defaultResultSetLimit; + } + + /** + * 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 + * @throws Zend_Search_Lucene_Exception + */ + public function getMaxBufferedDocs() + { + if (count($this->_indices) == 0) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices list is empty'); + } + + $maxBufferedDocs = reset($this->_indices)->getMaxBufferedDocs(); + + foreach ($this->_indices as $index) { + if ($index->getMaxBufferedDocs() !== $maxBufferedDocs) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices have different default search field.'); + } + } + + return $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) + { + foreach ($this->_indices as $index) { + $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 + * @throws Zend_Search_Lucene_Exception + */ + public function getMaxMergeDocs() + { + if (count($this->_indices) == 0) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices list is empty'); + } + + $maxMergeDocs = reset($this->_indices)->getMaxMergeDocs(); + + foreach ($this->_indices as $index) { + if ($index->getMaxMergeDocs() !== $maxMergeDocs) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices have different default search field.'); + } + } + + return $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) + { + foreach ($this->_indices as $index) { + $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 + * @throws Zend_Search_Lucene_Exception + */ + public function getMergeFactor() + { + if (count($this->_indices) == 0) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices list is empty'); + } + + $mergeFactor = reset($this->_indices)->getMergeFactor(); + + foreach ($this->_indices as $index) { + if ($index->getMergeFactor() !== $mergeFactor) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices have different default search field.'); + } + } + + return $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) + { + foreach ($this->_indices as $index) { + $index->setMaxMergeDocs($maxMergeDocs); + } + } + + /** + * 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) + { + $hitsList = array(); + + $indexShift = 0; + foreach ($this->_indices as $index) { + $hits = $index->find($query); + + if ($indexShift != 0) { + foreach ($hits as $hit) { + $hit->id += $indexShift; + } + } + + $indexShift += $index->count(); + $hitsList[] = $hits; + } + + /** @todo Implement advanced sorting */ + + return call_user_func_array('array_merge', $hitsList); + } + + /** + * Returns a list of all unique field names that exist in this index. + * + * @param boolean $indexed + * @return array + */ + public function getFieldNames($indexed = false) + { + $fieldNamesList = array(); + + foreach ($this->_indices as $index) { + $fieldNamesList[] = $index->getFieldNames($indexed); + } + + return array_unique(call_user_func_array('array_merge', $fieldNamesList)); + } + + /** + * 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; + } + + foreach ($this->_indices as $index) { + $indexCount = $index->count(); + + if ($indexCount > $id) { + return $index->getDocument($id); + } + + $id -= $indexCount; + } + + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Document id is out of the range.'); + } + + /** + * 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->_indices as $index) { + if ($index->hasTerm($term)) { + return true; + } + } + + return false; + } + + /** + * 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 + * @throws Zend_Search_Lucene_Exception + */ + public function termDocs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null) + { + if ($docsFilter != null) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher'); + } + + $docsList = array(); + + $indexShift = 0; + foreach ($this->_indices as $index) { + $docs = $index->termDocs($term); + + if ($indexShift != 0) { + foreach ($docs as $id => $docId) { + $docs[$id] += $indexShift; + } + } + + $indexShift += $index->count(); + $docsList[] = $docs; + } + + return call_user_func_array('array_merge', $docsList); + } + + /** + * 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 + * @throws Zend_Search_Lucene_Exception + */ + public function termDocsFilter(Zend_Search_Lucene_Index_Term $term, $docsFilter = null) + { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher'); + } + + /** + * 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 + * @throws Zend_Search_Lucene_Exception + */ + public function termFreqs(Zend_Search_Lucene_Index_Term $term, $docsFilter = null) + { + if ($docsFilter != null) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher'); + } + + $freqsList = array(); + + $indexShift = 0; + foreach ($this->_indices as $index) { + $freqs = $index->termFreqs($term); + + if ($indexShift != 0) { + $freqsShifted = array(); + + foreach ($freqs as $docId => $freq) { + $freqsShifted[$docId + $indexShift] = $freq; + } + $freqs = $freqsShifted; + } + + $indexShift += $index->count(); + $freqsList[] = $freqs; + } + + return call_user_func_array('array_merge', $freqsList); + } + + /** + * 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 + * @throws Zend_Search_Lucene_Exception + */ + public function termPositions(Zend_Search_Lucene_Index_Term $term, $docsFilter = null) + { + if ($docsFilter != null) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Document filters could not used with multi-searcher'); + } + + $termPositionsList = array(); + + $indexShift = 0; + foreach ($this->_indices as $index) { + $termPositions = $index->termPositions($term); + + if ($indexShift != 0) { + $termPositionsShifted = array(); + + foreach ($termPositions as $docId => $positions) { + $termPositions[$docId + $indexShift] = $positions; + } + $termPositions = $termPositionsShifted; + } + + $indexShift += $index->count(); + $termPositionsList[] = $termPositions; + } + + return call_user_func_array('array_merge', $termPositions); + } + + /** + * 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) + { + $docFreq = 0; + + foreach ($this->_indices as $index) { + $docFreq += $index->docFreq($term); + } + + return $docFreq; + } + + /** + * Retrive similarity used by index reader + * + * @return Zend_Search_Lucene_Search_Similarity + * @throws Zend_Search_Lucene_Exception + */ + public function getSimilarity() + { + if (count($this->_indices) == 0) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices list is empty'); + } + + $similarity = reset($this->_indices)->getSimilarity(); + + foreach ($this->_indices as $index) { + if ($index->getSimilarity() !== $similarity) { + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Indices have different similarity.'); + } + } + + return $similarity; + } + + /** + * Returns a normalization factor for "field, document" pair. + * + * @param integer $id + * @param string $fieldName + * @return float + */ + public function norm($id, $fieldName) + { + foreach ($this->_indices as $index) { + $indexCount = $index->count(); + + if ($indexCount > $id) { + return $index->norm($id, $fieldName); + } + + $id -= $indexCount; + } + + return null; + } + + /** + * Returns true if any documents have been deleted from this index. + * + * @return boolean + */ + public function hasDeletions() + { + foreach ($this->_indices as $index) { + if ($index->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) + { + foreach ($this->_indices as $index) { + $indexCount = $index->count(); + + if ($indexCount > $id) { + $index->delete($id); + return; + } + + $id -= $indexCount; + } + + require_once 'Zend/Search/Lucene/Exception.php'; + throw new Zend_Search_Lucene_Exception('Document id is out of the range.'); + } + + + /** + * Callback used to choose target index for new documents + * + * Function/method signature: + * Zend_Search_Lucene_Interface callbackFunction(Zend_Search_Lucene_Document $document, array $indices); + * + * null means "default documents distributing algorithm" + * + * @var callback + */ + protected $_documentDistributorCallBack = null; + + /** + * Set callback for choosing target index. + * + * @param callback $callback + */ + public function setDocumentDistributorCallback($callback) + { + if ($callback !== null && !is_callable($callback)) + $this->_documentDistributorCallBack = $callback; + } + + /** + * Get callback for choosing target index. + * + * @return callback + */ + public function getDocumentDistributorCallback() + { + return $this->_documentDistributorCallBack; + } + + /** + * Adds a document to this index. + * + * @param Zend_Search_Lucene_Document $document + * @throws Zend_Search_Lucene_Exception + */ + public function addDocument(Zend_Search_Lucene_Document $document) + { + if ($this->_documentDistributorCallBack !== null) { + $index = call_user_func($this->_documentDistributorCallBack, $document, $this->_indices); + } else { + $index = $this->_indices[ array_rand($this->_indices) ]; + } + + $index->addDocument($document); + } + + /** + * Commit changes resulting from delete() or undeleteAll() operations. + */ + public function commit() + { + foreach ($this->_indices as $index) { + $index->commit(); + } + } + + /** + * Optimize index. + * + * Merges all segments into one + */ + public function optimize() + { + foreach ($this->_indices as $index) { + $index->_optimise(); + } + } + + /** + * Returns an array of all terms in this index. + * + * @return array + */ + public function terms() + { + $termsList = array(); + + foreach ($this->_indices as $index) { + $termsList[] = $index->terms(); + } + + return array_unique(call_user_func_array('array_merge', $termsList)); + } + + + /** + * 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->_indices); + } 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; + } + + + /** + * Undeletes all documents currently marked as deleted in this index. + */ + public function undeleteAll() + { + foreach ($this->_indices as $index) { + $index->undeleteAll(); + } + } + + + /** + * Add reference to the index object + * + * @internal + */ + public function addReference() + { + // Do nothing, since it's never referenced by indices + } + + /** + * Remove reference from the index object + * + * When reference count becomes zero, index is closed and resources are cleaned up + * + * @internal + */ + public function removeReference() + { + // Do nothing, since it's never referenced by indices + } +} diff --git a/Zend/Search/Lucene/PriorityQueue.php b/Zend/Search/Lucene/PriorityQueue.php new file mode 100644 index 00000000..5de3e537 --- /dev/null +++ b/Zend/Search/Lucene/PriorityQueue.php @@ -0,0 +1,171 @@ +_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/Zend/Search/Lucene/Proxy.php b/Zend/Search/Lucene/Proxy.php new file mode 100644 index 00000000..5164968b --- /dev/null +++ b/Zend/Search/Lucene/Proxy.php @@ -0,0 +1,612 @@ +_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/Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php b/Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php new file mode 100644 index 00000000..3fd40190 --- /dev/null +++ b/Zend/Search/Lucene/Search/BooleanExpressionRecognizer.php @@ -0,0 +1,278 @@ +, ) + * + * So, it has a structure: + * array( array( array(, ), // first literal of first conjuction + * array(, ), // second literal of first conjuction + * ... + * array(, ) + * ), // end of first conjuction + * array( array(, ), // first literal of second conjuction + * array(, ), // second literal of second conjuction + * ... + * array(, ) + * ), // 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(, ) + * + * So, it has a structure: + * array( array( array(, ), // first literal of first conjuction + * array(, ), // second literal of first conjuction + * ... + * array(, ) + * ), // end of first conjuction + * array( array(, ), // first literal of second conjuction + * array(, ), // second literal of second conjuction + * ... + * array(, ) + * ), // 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/Zend/Search/Lucene/Search/Highlighter/Default.php b/Zend/Search/Lucene/Search/Highlighter/Default.php new file mode 100644 index 00000000..ed59b35f --- /dev/null +++ b/Zend/Search/Lucene/Search/Highlighter/Default.php @@ -0,0 +1,94 @@ +_doc = $document; + } + + /** + * Get document for highlighting. + * + * @return Zend_Search_Lucene_Document_Html $document + */ + public function getDocument() + { + return $this->_doc; + } + + /** + * Highlight specified words + * + * @param string|array $words Words to highlight. They could be organized using the array or string. + */ + public function highlight($words) + { + $color = $this->_highlightColors[$this->_currentColorIndex]; + $this->_currentColorIndex = ($this->_currentColorIndex + 1) % count($this->_highlightColors); + + $this->_doc->highlight($words, $color); + } + +} diff --git a/Zend/Search/Lucene/Search/Highlighter/Interface.php b/Zend/Search/Lucene/Search/Highlighter/Interface.php new file mode 100644 index 00000000..bf13871f --- /dev/null +++ b/Zend/Search/Lucene/Search/Highlighter/Interface.php @@ -0,0 +1,53 @@ +_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 = '' + . iconv($encoding, 'UTF-8//IGNORE', $inputHtmlFragment) . ''; + + $doc = Zend_Search_Lucene_Document_Html::loadHTML($inputHTML); + $highlighter->setDocument($doc); + + $this->_highlightMatches($highlighter); + + return $doc->getHtmlBody(); + } +} + diff --git a/Zend/Search/Lucene/Search/Query/Boolean.php b/Zend/Search/Lucene/Search/Query/Boolean.php new file mode 100644 index 00000000..781b602f --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Boolean.php @@ -0,0 +1,806 @@ +_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 ' AND AND ') + */ + 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 ' AND AND NOT OR ') + */ + 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/Zend/Search/Lucene/Search/Query/Empty.php b/Zend/Search/Lucene/Search/Query/Empty.php new file mode 100644 index 00000000..2c6b935c --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Empty.php @@ -0,0 +1,140 @@ +'; + } +} + diff --git a/Zend/Search/Lucene/Search/Query/Fuzzy.php b/Zend/Search/Lucene/Search/Query/Fuzzy.php new file mode 100644 index 00000000..8f8d78c4 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Fuzzy.php @@ -0,0 +1,488 @@ += 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/Zend/Search/Lucene/Search/Query/Insignificant.php b/Zend/Search/Lucene/Search/Query/Insignificant.php new file mode 100644 index 00000000..16d22d08 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Insignificant.php @@ -0,0 +1,141 @@ +'; + } +} + diff --git a/Zend/Search/Lucene/Search/Query/MultiTerm.php b/Zend/Search/Lucene/Search/Query/MultiTerm.php new file mode 100644 index 00000000..c57bcb54 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/MultiTerm.php @@ -0,0 +1,661 @@ + (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/Zend/Search/Lucene/Search/Query/Phrase.php b/Zend/Search/Lucene/Search/Query/Phrase.php new file mode 100644 index 00000000..a98c5901 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Phrase.php @@ -0,0 +1,571 @@ + (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/Zend/Search/Lucene/Search/Query/Preprocessing.php b/Zend/Search/Lucene/Search/Query/Preprocessing.php new file mode 100644 index 00000000..4a0f4aaa --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Preprocessing.php @@ -0,0 +1,134 @@ +_word = $word; + $this->_encoding = $encoding; + $this->_field = $fieldName; + $this->_minimumSimilarity = $minimumSimilarity; + } + + /** + * 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->_field === null) { + $query = new Zend_Search_Lucene_Search_Query_Boolean(); + + $hasInsignificantSubqueries = false; + + if (Zend_Search_Lucene::getDefaultSearchField() === null) { + $searchFields = $index->getFieldNames(true); + } else { + $searchFields = array(Zend_Search_Lucene::getDefaultSearchField()); + } + + foreach ($searchFields as $fieldName) { + $subquery = new Zend_Search_Lucene_Search_Query_Preprocessing_Fuzzy($this->_word, + $this->_encoding, + $fieldName, + $this->_minimumSimilarity); + + $rewrittenSubquery = $subquery->rewrite($index); + + if ( !($rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Insignificant || + $rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Empty) ) { + $query->addSubquery($rewrittenSubquery); + } + + if ($rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Insignificant) { + $hasInsignificantSubqueries = true; + } + } + + $subqueries = $query->getSubqueries(); + + if (count($subqueries) == 0) { + $this->_matches = array(); + if ($hasInsignificantSubqueries) { + return new Zend_Search_Lucene_Search_Query_Insignificant(); + } else { + return new Zend_Search_Lucene_Search_Query_Empty(); + } + } + + if (count($subqueries) == 1) { + $query = reset($subqueries); + } + + $query->setBoost($this->getBoost()); + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + // ------------------------------------- + // Recognize exact term matching (it corresponds to Keyword fields stored in the index) + // encoding is not used since we expect binary matching + $term = new Zend_Search_Lucene_Index_Term($this->_word, $this->_field); + if ($index->hasTerm($term)) { + $query = new Zend_Search_Lucene_Search_Query_Fuzzy($term, $this->_minimumSimilarity); + $query->setBoost($this->getBoost()); + + // Get rewritten query. Important! It also fills terms matching container. + $rewrittenQuery = $query->rewrite($index); + $this->_matches = $query->getQueryTerms(); + + return $rewrittenQuery; + } + + + // ------------------------------------- + // Recognize wildcard queries + + /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */ + if (@preg_match('/\pL/u', 'a') == 1) { + $subPatterns = preg_split('/[*?]/u', iconv($this->_encoding, 'UTF-8', $this->_word)); + } else { + $subPatterns = preg_split('/[*?]/', $this->_word); + } + if (count($subPatterns) > 1) { + require_once 'Zend/Search/Lucene/Search/QueryParserException.php'; + throw new Zend_Search_Lucene_Search_QueryParserException('Fuzzy search doesn\'t support wildcards (except within Keyword fields).'); + } + + + // ------------------------------------- + // Recognize one-term multi-term and "insignificant" queries + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding); + + if (count($tokens) == 0) { + $this->_matches = array(); + return new Zend_Search_Lucene_Search_Query_Insignificant(); + } + + if (count($tokens) == 1) { + $term = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field); + $query = new Zend_Search_Lucene_Search_Query_Fuzzy($term, $this->_minimumSimilarity); + $query->setBoost($this->getBoost()); + + // Get rewritten query. Important! It also fills terms matching container. + $rewrittenQuery = $query->rewrite($index); + $this->_matches = $query->getQueryTerms(); + + return $rewrittenQuery; + } + + // Word is tokenized into several tokens + require_once 'Zend/Search/Lucene/Search/QueryParserException.php'; + throw new Zend_Search_Lucene_Search_QueryParserException('Fuzzy search is supported only for non-multiple word 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) + { + /** Skip fields detection. We don't need it, since we expect all fields presented in the HTML body and don't differentiate them */ + + /** Skip exact term matching recognition, keyword fields highlighting is not supported */ + + // ------------------------------------- + // Recognize wildcard queries + + /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */ + if (@preg_match('/\pL/u', 'a') == 1) { + $subPatterns = preg_split('/[*?]/u', iconv($this->_encoding, 'UTF-8', $this->_word)); + } else { + $subPatterns = preg_split('/[*?]/', $this->_word); + } + if (count($subPatterns) > 1) { + // Do nothing + return; + } + + // ------------------------------------- + // Recognize one-term multi-term and "insignificant" queries + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding); + if (count($tokens) == 0) { + // Do nothing + return; + } + if (count($tokens) == 1) { + $term = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field); + $query = new Zend_Search_Lucene_Search_Query_Fuzzy($term, $this->_minimumSimilarity); + + $query->_highlightMatches($highlighter); + return; + } + + // Word is tokenized into several tokens + // But fuzzy search is supported only for non-multiple word terms + // Do nothing + } + + /** + * 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->_field !== null) { + $query = $this->_field . ':'; + } else { + $query = ''; + } + + $query .= $this->_word; + + if ($this->getBoost() != 1) { + $query .= '^' . round($this->getBoost(), 4); + } + + return $query; + } +} diff --git a/Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php b/Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php new file mode 100644 index 00000000..6ec236c1 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Preprocessing/Phrase.php @@ -0,0 +1,274 @@ +_phrase = $phrase; + $this->_phraseEncoding = $phraseEncoding; + $this->_field = $fieldName; + } + + /** + * Set slop + * + * @param integer $slop + */ + public function setSlop($slop) + { + $this->_slop = $slop; + } + + + /** + * Get slop + * + * @return integer + */ + public function getSlop() + { + return $this->_slop; + } + + /** + * 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) + { +// Allow to use wildcards within phrases +// They are either removed by text analyzer or used as a part of keyword for keyword fields +// +// if (strpos($this->_phrase, '?') !== false || strpos($this->_phrase, '*') !== false) { +// require_once 'Zend/Search/Lucene/Search/QueryParserException.php'; +// throw new Zend_Search_Lucene_Search_QueryParserException('Wildcards are only allowed in a single terms.'); +// } + + // Split query into subqueries if field name is not specified + if ($this->_field === null) { + $query = new Zend_Search_Lucene_Search_Query_Boolean(); + $query->setBoost($this->getBoost()); + + if (Zend_Search_Lucene::getDefaultSearchField() === null) { + $searchFields = $index->getFieldNames(true); + } else { + $searchFields = array(Zend_Search_Lucene::getDefaultSearchField()); + } + + foreach ($searchFields as $fieldName) { + $subquery = new Zend_Search_Lucene_Search_Query_Preprocessing_Phrase($this->_phrase, + $this->_phraseEncoding, + $fieldName); + $subquery->setSlop($this->getSlop()); + + $query->addSubquery($subquery->rewrite($index)); + } + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + // Recognize exact term matching (it corresponds to Keyword fields stored in the index) + // encoding is not used since we expect binary matching + $term = new Zend_Search_Lucene_Index_Term($this->_phrase, $this->_field); + if ($index->hasTerm($term)) { + $query = new Zend_Search_Lucene_Search_Query_Term($term); + $query->setBoost($this->getBoost()); + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + + // tokenize phrase using current analyzer and process it as a phrase query + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_phrase, $this->_phraseEncoding); + + if (count($tokens) == 0) { + $this->_matches = array(); + return new Zend_Search_Lucene_Search_Query_Insignificant(); + } + + if (count($tokens) == 1) { + $term = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field); + $query = new Zend_Search_Lucene_Search_Query_Term($term); + $query->setBoost($this->getBoost()); + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + //It's non-trivial phrase query + $position = -1; + $query = new Zend_Search_Lucene_Search_Query_Phrase(); + foreach ($tokens as $token) { + $position += $token->getPositionIncrement(); + $term = new Zend_Search_Lucene_Index_Term($token->getTermText(), $this->_field); + $query->addTerm($term, $position); + $query->setSlop($this->getSlop()); + } + $this->_matches = $query->getQueryTerms(); + return $query; + } + + /** + * 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) + { + /** Skip fields detection. We don't need it, since we expect all fields presented in the HTML body and don't differentiate them */ + + /** Skip exact term matching recognition, keyword fields highlighting is not supported */ + + /** Skip wildcard queries recognition. Supported wildcards are removed by text analyzer */ + + // tokenize phrase using current analyzer and process it as a phrase query + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_phrase, $this->_phraseEncoding); + + if (count($tokens) == 0) { + // Do nothing + return; + } + + if (count($tokens) == 1) { + $highlighter->highlight($tokens[0]->getTermText()); + return; + } + + //It's non-trivial phrase query + $words = array(); + foreach ($tokens as $token) { + $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->_field !== null) { + $query = $this->_field . ':'; + } else { + $query = ''; + } + + $query .= '"' . $this->_phrase . '"'; + + if ($this->_slop != 0) { + $query .= '~' . $this->_slop; + } + + if ($this->getBoost() != 1) { + $query .= '^' . round($this->getBoost(), 4); + } + + return $query; + } +} diff --git a/Zend/Search/Lucene/Search/Query/Preprocessing/Term.php b/Zend/Search/Lucene/Search/Query/Preprocessing/Term.php new file mode 100644 index 00000000..720a8928 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Preprocessing/Term.php @@ -0,0 +1,335 @@ +_word = $word; + $this->_encoding = $encoding; + $this->_field = $fieldName; + } + + /** + * 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->_field === null) { + $query = new Zend_Search_Lucene_Search_Query_MultiTerm(); + $query->setBoost($this->getBoost()); + + $hasInsignificantSubqueries = false; + + if (Zend_Search_Lucene::getDefaultSearchField() === null) { + $searchFields = $index->getFieldNames(true); + } else { + $searchFields = array(Zend_Search_Lucene::getDefaultSearchField()); + } + + foreach ($searchFields as $fieldName) { + $subquery = new Zend_Search_Lucene_Search_Query_Preprocessing_Term($this->_word, + $this->_encoding, + $fieldName); + $rewrittenSubquery = $subquery->rewrite($index); + foreach ($rewrittenSubquery->getQueryTerms() as $term) { + $query->addTerm($term); + } + + if ($rewrittenSubquery instanceof Zend_Search_Lucene_Search_Query_Insignificant) { + $hasInsignificantSubqueries = true; + } + } + + if (count($query->getTerms()) == 0) { + $this->_matches = array(); + if ($hasInsignificantSubqueries) { + return new Zend_Search_Lucene_Search_Query_Insignificant(); + } else { + return new Zend_Search_Lucene_Search_Query_Empty(); + } + } + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + // ------------------------------------- + // Recognize exact term matching (it corresponds to Keyword fields stored in the index) + // encoding is not used since we expect binary matching + $term = new Zend_Search_Lucene_Index_Term($this->_word, $this->_field); + if ($index->hasTerm($term)) { + $query = new Zend_Search_Lucene_Search_Query_Term($term); + $query->setBoost($this->getBoost()); + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + + // ------------------------------------- + // Recognize wildcard queries + + /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */ + if (@preg_match('/\pL/u', 'a') == 1) { + $word = iconv($this->_encoding, 'UTF-8', $this->_word); + $wildcardsPattern = '/[*?]/u'; + $subPatternsEncoding = 'UTF-8'; + } else { + $word = $this->_word; + $wildcardsPattern = '/[*?]/'; + $subPatternsEncoding = $this->_encoding; + } + + $subPatterns = preg_split($wildcardsPattern, $word, -1, PREG_SPLIT_OFFSET_CAPTURE); + + if (count($subPatterns) > 1) { + // Wildcard query is recognized + + $pattern = ''; + + foreach ($subPatterns as $id => $subPattern) { + // Append corresponding wildcard character to the pattern before each sub-pattern (except first) + if ($id != 0) { + $pattern .= $word[ $subPattern[1] - 1 ]; + } + + // Check if each subputtern is a single word in terms of current analyzer + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($subPattern[0], $subPatternsEncoding); + if (count($tokens) > 1) { + require_once 'Zend/Search/Lucene/Search/QueryParserException.php'; + throw new Zend_Search_Lucene_Search_QueryParserException('Wildcard search is supported only for non-multiple word terms'); + } + foreach ($tokens as $token) { + $pattern .= $token->getTermText(); + } + } + + $term = new Zend_Search_Lucene_Index_Term($pattern, $this->_field); + $query = new Zend_Search_Lucene_Search_Query_Wildcard($term); + $query->setBoost($this->getBoost()); + + // Get rewritten query. Important! It also fills terms matching container. + $rewrittenQuery = $query->rewrite($index); + $this->_matches = $query->getQueryTerms(); + + return $rewrittenQuery; + } + + + // ------------------------------------- + // Recognize one-term multi-term and "insignificant" queries + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding); + + if (count($tokens) == 0) { + $this->_matches = array(); + return new Zend_Search_Lucene_Search_Query_Insignificant(); + } + + if (count($tokens) == 1) { + $term = new Zend_Search_Lucene_Index_Term($tokens[0]->getTermText(), $this->_field); + $query = new Zend_Search_Lucene_Search_Query_Term($term); + $query->setBoost($this->getBoost()); + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + //It's not insignificant or one term query + $query = new Zend_Search_Lucene_Search_Query_MultiTerm(); + + /** + * @todo Process $token->getPositionIncrement() to support stemming, synonyms and other + * analizer design features + */ + foreach ($tokens as $token) { + $term = new Zend_Search_Lucene_Index_Term($token->getTermText(), $this->_field); + $query->addTerm($term, true); // all subterms are required + } + + $query->setBoost($this->getBoost()); + + $this->_matches = $query->getQueryTerms(); + return $query; + } + + /** + * 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) + { + /** Skip fields detection. We don't need it, since we expect all fields presented in the HTML body and don't differentiate them */ + + /** Skip exact term matching recognition, keyword fields highlighting is not supported */ + + // ------------------------------------- + // Recognize wildcard queries + /** @todo check for PCRE unicode support may be performed through Zend_Environment in some future */ + if (@preg_match('/\pL/u', 'a') == 1) { + $word = iconv($this->_encoding, 'UTF-8', $this->_word); + $wildcardsPattern = '/[*?]/u'; + $subPatternsEncoding = 'UTF-8'; + } else { + $word = $this->_word; + $wildcardsPattern = '/[*?]/'; + $subPatternsEncoding = $this->_encoding; + } + $subPatterns = preg_split($wildcardsPattern, $word, -1, PREG_SPLIT_OFFSET_CAPTURE); + if (count($subPatterns) > 1) { + // Wildcard query is recognized + + $pattern = ''; + + foreach ($subPatterns as $id => $subPattern) { + // Append corresponding wildcard character to the pattern before each sub-pattern (except first) + if ($id != 0) { + $pattern .= $word[ $subPattern[1] - 1 ]; + } + + // Check if each subputtern is a single word in terms of current analyzer + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($subPattern[0], $subPatternsEncoding); + if (count($tokens) > 1) { + // Do nothing (nothing is highlighted) + return; + } + foreach ($tokens as $token) { + $pattern .= $token->getTermText(); + } + } + + $term = new Zend_Search_Lucene_Index_Term($pattern, $this->_field); + $query = new Zend_Search_Lucene_Search_Query_Wildcard($term); + + $query->_highlightMatches($highlighter); + return; + } + + // ------------------------------------- + // Recognize one-term multi-term and "insignificant" queries + $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($this->_word, $this->_encoding); + + if (count($tokens) == 0) { + // Do nothing + return; + } + + if (count($tokens) == 1) { + $highlighter->highlight($tokens[0]->getTermText()); + return; + } + + //It's not insignificant or one term query + $words = array(); + foreach ($tokens as $token) { + $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->_field !== null) { + $query = $this->_field . ':'; + } else { + $query = ''; + } + + $query .= $this->_word; + + if ($this->getBoost() != 1) { + $query .= '^' . round($this->getBoost(), 4); + } + + return $query; + } +} diff --git a/Zend/Search/Lucene/Search/Query/Range.php b/Zend/Search/Lucene/Search/Query/Range.php new file mode 100644 index 00000000..9e158e0c --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Range.php @@ -0,0 +1,377 @@ +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/Zend/Search/Lucene/Search/Query/Term.php b/Zend/Search/Lucene/Search/Query/Term.php new file mode 100644 index 00000000..2df9f869 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Term.php @@ -0,0 +1,227 @@ + 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/Zend/Search/Lucene/Search/Query/Wildcard.php b/Zend/Search/Lucene/Search/Query/Wildcard.php new file mode 100644 index 00000000..a1bf9b86 --- /dev/null +++ b/Zend/Search/Lucene/Search/Query/Wildcard.php @@ -0,0 +1,351 @@ +_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/Zend/Search/Lucene/Search/QueryEntry.php b/Zend/Search/Lucene/Search/QueryEntry.php new file mode 100644 index 00000000..d25f7e89 --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryEntry.php @@ -0,0 +1,79 @@ +_boost *= $boostFactor; + } + + +} diff --git a/Zend/Search/Lucene/Search/QueryEntry/Phrase.php b/Zend/Search/Lucene/Search/QueryEntry/Phrase.php new file mode 100644 index 00000000..dc8ec39d --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryEntry/Phrase.php @@ -0,0 +1,120 @@ +_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/Zend/Search/Lucene/Search/QueryEntry/Subquery.php b/Zend/Search/Lucene/Search/QueryEntry/Subquery.php new file mode 100644 index 00000000..f0fec48e --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryEntry/Subquery.php @@ -0,0 +1,80 @@ +_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/Zend/Search/Lucene/Search/QueryEntry/Term.php b/Zend/Search/Lucene/Search/QueryEntry/Term.php new file mode 100644 index 00000000..80016373 --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryEntry/Term.php @@ -0,0 +1,130 @@ +_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/Zend/Search/Lucene/Search/QueryHit.php b/Zend/Search/Lucene/Search/QueryHit.php new file mode 100644 index 00000000..ae3910af --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryHit.php @@ -0,0 +1,109 @@ +_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/Zend/Search/Lucene/Search/QueryLexer.php b/Zend/Search/Lucene/Search/QueryLexer.php new file mode 100644 index 00000000..139d6526 --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryLexer.php @@ -0,0 +1,510 @@ +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/Zend/Search/Lucene/Search/QueryParser.php b/Zend/Search/Lucene/Search/QueryParser.php new file mode 100644 index 00000000..41360e57 --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryParser.php @@ -0,0 +1,636 @@ +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/Zend/Search/Lucene/Search/QueryParserContext.php b/Zend/Search/Lucene/Search/QueryParserContext.php new file mode 100644 index 00000000..1f3bd92d --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryParserContext.php @@ -0,0 +1,418 @@ +_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 +() ...' + * + * @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 () and not ()' + * + * @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/Zend/Search/Lucene/Search/QueryParserException.php b/Zend/Search/Lucene/Search/QueryParserException.php new file mode 100644 index 00000000..e17bd934 --- /dev/null +++ b/Zend/Search/Lucene/Search/QueryParserException.php @@ -0,0 +1,41 @@ + or field:() 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/Zend/Search/Lucene/Search/Similarity.php b/Zend/Search/Lucene/Search/Similarity.php new file mode 100644 index 00000000..cd16414f --- /dev/null +++ b/Zend/Search/Lucene/Search/Similarity.php @@ -0,0 +1,554 @@ + 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/Zend/Search/Lucene/Search/Similarity/Default.php b/Zend/Search/Lucene/Search/Similarity/Default.php new file mode 100644 index 00000000..8bcd3f06 --- /dev/null +++ b/Zend/Search/Lucene/Search/Similarity/Default.php @@ -0,0 +1,110 @@ +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/Zend/Search/Lucene/Search/Weight/Boolean.php b/Zend/Search/Lucene/Search/Weight/Boolean.php new file mode 100644 index 00000000..535411ce --- /dev/null +++ b/Zend/Search/Lucene/Search/Weight/Boolean.php @@ -0,0 +1,137 @@ +_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/Zend/Search/Lucene/Search/Weight/Empty.php b/Zend/Search/Lucene/Search/Weight/Empty.php new file mode 100644 index 00000000..c1f525fe --- /dev/null +++ b/Zend/Search/Lucene/Search/Weight/Empty.php @@ -0,0 +1,57 @@ +_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/Zend/Search/Lucene/Search/Weight/Phrase.php b/Zend/Search/Lucene/Search/Weight/Phrase.php new file mode 100644 index 00000000..34656cc8 --- /dev/null +++ b/Zend/Search/Lucene/Search/Weight/Phrase.php @@ -0,0 +1,108 @@ +_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/Zend/Search/Lucene/Search/Weight/Term.php b/Zend/Search/Lucene/Search/Weight/Term.php new file mode 100644 index 00000000..13585e3d --- /dev/null +++ b/Zend/Search/Lucene/Search/Weight/Term.php @@ -0,0 +1,125 @@ +_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/Zend/Search/Lucene/Storage/Directory.php b/Zend/Search/Lucene/Storage/Directory.php new file mode 100644 index 00000000..e124ad33 --- /dev/null +++ b/Zend/Search/Lucene/Storage/Directory.php @@ -0,0 +1,136 @@ + 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/Zend/Search/Lucene/Storage/File.php b/Zend/Search/Lucene/Storage/File.php new file mode 100644 index 00000000..8673bf05 --- /dev/null +++ b/Zend/Search/Lucene/Storage/File.php @@ -0,0 +1,473 @@ +_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/Zend/Search/Lucene/Storage/File/Filesystem.php b/Zend/Search/Lucene/Storage/File/Filesystem.php new file mode 100644 index 00000000..69f5f97f --- /dev/null +++ b/Zend/Search/Lucene/Storage/File/Filesystem.php @@ -0,0 +1,220 @@ +_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/Zend/Search/Lucene/Storage/File/Memory.php b/Zend/Search/Lucene/Storage/File/Memory.php new file mode 100644 index 00000000..2ec06ad5 --- /dev/null +++ b/Zend/Search/Lucene/Storage/File/Memory.php @@ -0,0 +1,601 @@ +_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/Zend/Search/Lucene/TermStreamsPriorityQueue.php b/Zend/Search/Lucene/TermStreamsPriorityQueue.php new file mode 100644 index 00000000..f8c4527c --- /dev/null +++ b/Zend/Search/Lucene/TermStreamsPriorityQueue.php @@ -0,0 +1,176 @@ +_termStreams = $termStreams; + + $this->resetTermsStream(); + } + + /** + * Reset terms stream. + */ + public function resetTermsStream() + { + $this->_termsStreamQueue = new Zend_Search_Lucene_Index_TermsPriorityQueue(); + + foreach ($this->_termStreams as $termStream) { + $termStream->resetTermsStream(); + + // Skip "empty" containers + if ($termStream->currentTerm() !== null) { + $this->_termsStreamQueue->put($termStream); + } + } + + $this->nextTerm(); + } + + /** + * 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) + { + $termStreams = array(); + + while (($termStream = $this->_termsStreamQueue->pop()) !== null) { + $termStreams[] = $termStream; + } + + foreach ($termStreams as $termStream) { + $termStream->skipTo($prefix); + + if ($termStream->currentTerm() !== null) { + $this->_termsStreamQueue->put($termStream); + } + } + + $this->nextTerm(); + } + + /** + * Scans term streams and returns next term + * + * @return Zend_Search_Lucene_Index_Term|null + */ + public function nextTerm() + { + while (($termStream = $this->_termsStreamQueue->pop()) !== null) { + if ($this->_termsStreamQueue->top() === null || + $this->_termsStreamQueue->top()->currentTerm()->key() != + $termStream->currentTerm()->key()) { + // We got new term + $this->_lastTerm = $termStream->currentTerm(); + + if ($termStream->nextTerm() !== null) { + // Put segment back into the priority queue + $this->_termsStreamQueue->put($termStream); + } + + return $this->_lastTerm; + } + + if ($termStream->nextTerm() !== null) { + // Put segment back into the priority queue + $this->_termsStreamQueue->put($termStream); + } + } + + // End of stream + $this->_lastTerm = null; + + return null; + } + + /** + * Returns term in current position + * + * @return Zend_Search_Lucene_Index_Term|null + */ + public function currentTerm() + { + 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() + { + while (($termStream = $this->_termsStreamQueue->pop()) !== null) { + $termStream->closeTermsStream(); + } + + $this->_termsStreamQueue = null; + $this->_lastTerm = null; + } +} diff --git a/admin/auth.php b/admin/auth.php new file mode 100644 index 00000000..e3189ea8 --- /dev/null +++ b/admin/auth.php @@ -0,0 +1,294 @@ + $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)" : ''; + + +?> +

      Authentication

      + + + $message +
      +HTML; +} + + +?> +

      +Select one of the following, depending +on which one best matches your intended mtrack deployment: +

      + +
      +
      +

      Private (HTTP authentication)

      +
      +

      + I want to strictly control who has access to mtrack, and prevent + anonymous users from having any rights. +

      + +

      + It looks like your web server is configured to use HTTP authentication + (you're authenticated as ) + 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. +

      + +

      + mtrack will use HTTP authentication and store the password and group + files in the vardir. +

      +Administrators"; +$admins = get_admins(); +if (count($admins)) { + echo "

      The following users are configured with admin rights:

      "; + echo "

      "; + foreach ($admins as $id) { + echo mtrack_username($id) . " "; + } + echo "

      "; +} else { + echo <<You MUST add at least one user as an administrator, +otherwise no one will be able to administer the system without editing +the config.ini file. +

      +HTML; + + echo << + +Add Admin User: + + +HTML; + + if (!isset($_SERVER['REMOTE_USER'])) { + echo << + Set Password: + + + + Confirm Password: + + + +HTML; + } else { + echo << +

      +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

      +HTML; + } +} +?> + + +
      +

      Public (OpenID)

      +
      +

      + I want to allow the public access to mtrack, but only allow people that + I trust to make certain kinds of changes. +

      +

      + mtrack will use OpenID to manage authentication. +

      +

      Administrators

      +The following OpenID users are configured with admin rights:

      "; + echo "

      "; + foreach ($admins as $id) { + echo mtrack_username($id) . " ($id) "; + } + echo "

      "; +} else { + echo <<You MUST add at least one OpenID as an administrator, +otherwise no one will be able to administer the system without editing +the config.ini file. +

      +HTML; +} +?> +Add Admin OpenID:
      +Local Username:
      + +
      +
      +
      + +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 "
      "; +echo "
      Components
      \n"; +echo "\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 "" . + "" . + "" . + "" . + "\n"; +} + +echo "" . + "\n"; + +echo "
      NameProjectsDeleted
      " . mtrack_multi_select_box("comp:$compid:projects", + "(select to add)", $projects, $p_by_c[$compid]) . + "
      " . mtrack_multi_select_box('newcomponentprojects', + "(select to add)", $projects) . + "Add a new Component
      \n"; + +echo "
      "; + +mtrack_foot(); + diff --git a/admin/customfield.php b/admin/customfield.php new file mode 100644 index 00000000..6c5db508 --- /dev/null +++ b/admin/customfield.php @@ -0,0 +1,199 @@ +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 "

      Custom Fields

      "; + + +$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; +?> +
      +
      + Edit Custom Field + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

      + 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.

      + The label to display on the ticket screen

      + Fields with the same group are grouped together on the ticket + editing screen

      + Enter the default value for this field

      + For Select and Multi-Select types, enter a list of possible + choices here, separated by a pipe character |
      +
      + Lower means show first. If two or more fields have same 'order', + then they are ordered by name +
      + + + +
      +
      + + + +getGroupedFields(); + +foreach ($grouped as $groupname => $group) { + $groupname = htmlentities($groupname, ENT_QUOTES, 'utf-8'); + echo "Group: $groupname
      \n\n"; + foreach ($group as $field) { + $type = $field->type; + $label = htmlentities($field->label, ENT_QUOTES, 'utf-8'); + $name = $field->name; + $name = "$name"; + echo "\n"; + } + echo "
      $name$type$label
      \n"; +} + +?> +
      + + +
      +shortname"); + $S->deleteRepo($CS); + $CS->commit(); +} + +header("Location: ${ABSWEB}browse.php"); +exit; + diff --git a/admin/enum.php b/admin/enum.php new file mode 100644 index 00000000..77580b49 --- /dev/null +++ b/admin/enum.php @@ -0,0 +1,88 @@ +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 "
      "; + +$cls = 'MTrack' . $ename; +$obj = new $cls; +echo "
      $ename values
      \n"; +$vals = $obj->enumerate(true); +echo "\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 "" . + "" . + "" . + "" . + "\n"; +} +echo "" . + "" . + "" . + "" . + "\n"; +echo "
      NameValueDeleted
      $n
      Add a new $ename
      \n"; + +echo "
      "; + +mtrack_foot(); + diff --git a/admin/forkrepo.php b/admin/forkrepo.php new file mode 100644 index 00000000..c06b724b --- /dev/null +++ b/admin/forkrepo.php @@ -0,0 +1,53 @@ +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/admin/group.php b/admin/group.php new file mode 100644 index 00000000..2ad19b8f --- /dev/null +++ b/admin/group.php @@ -0,0 +1,87 @@ +commit(); + header("Location: {$ABSWEB}admin/project.php?edit=$pid"); + exit; +} + +mtrack_head($group ? "$P->name - $group" : "$P->name - New Group"); + +echo "
      "; +if ($group) { + echo "

      " . htmlentities("$P->name - $group", ENT_QUOTES, 'utf-8') . "

      "; + echo ""; +} else { + echo "

      " . htmlentities("$P->name - New Group", ENT_QUOTES, 'utf-8') . "

      "; + echo "Group: "; + echo ""; +} + +$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 ""; + +echo "
      "; + +mtrack_foot(); + diff --git a/admin/importcsv.php b/admin/importcsv.php new file mode 100644 index 00000000..a79651aa --- /dev/null +++ b/admin/importcsv.php @@ -0,0 +1,325 @@ + '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[] = "Updating ticket $tkt->nsident
      \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[] = "Creating ticket $tkt->nsident
      \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
      \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:
      \n"; + foreach ($err as $msg) { + echo htmlentities($msg) . "
      \n"; + } + echo "
      No changes were committed
      \n"; + } else { + echo "
      Done!\n"; + } + + mtrack_foot(); + exit; +} + +mtrack_head('Import'); + +?> +

      Import/Update via CSV

      + +

      +You may use this facility to change ticket properties en-masse by uploading +a CSV file. +

      + +
        +
      • If a ticket column is present and non-empty, + that ticket will be updated
      • +
      • If there is no ticket column, or the ticket column is empty, + then a ticket will be created
      • +
      • If any errors are detected, none of the changes from the CSV file + will be applied
      • +
      + +

      +The input file must be a CSV file with the field names on the first line. +

      + +

      +The following fields are supported: +

      + +
      +
      ticket
      +
      The ticket number
      + +
      milestone
      +
      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. +
      + +
      -milestone
      +
      Removes a milestone; if the ticket is associated with the named milestone, + it will be removed from that milestone. +
      + +
      +milestone
      +
      Associates the ticket with the named milestone, preserving any other + milestones currently associated with the ticket. +
      + +
      summary
      +
      Sets the summary for the ticket
      + +
      status or state
      +
      Sets the state of the ticket; can be one of the configured ticket states +
      + +
      priority
      +
      Sets the priority; can be one of the configured priorities
      + +
      owner
      +
      Sets the owner
      + +
      type
      +
      Sets the ticket type
      + +
      component
      +
      Sets the component, replacing all other component associations
      + +
      -component
      +
      Removes association with the named component
      + +
      +component
      +
      Associates with the named component, preserving existing associations
      + +
      description
      +
      Sets the description of the ticket
      + +fields as $f) { + $name = substr($f->name, 2); + if (!isset($field_aliases[$name]) || $field_aliases[$name] != $f->name) { + $name = $f->name; + echo "
      $name
      \n"; + } else { + echo "
      $name
      \n"; + echo "
      $f->name
      \n"; + } + echo "
      " . htmlentities($f->label, ENT_QUOTES, 'utf-8') . "\n"; + + if ($f->type == 'select') { + echo "
      Value may be one of:
      "; + $data = $f->ticketData(); + foreach ($data['options'] as $opt) { + echo " " . htmlentities($opt, ENT_QUOTES, 'utf-8') . "
      "; + } + } + + echo "
      \n"; +} + +?> + +
      + +

      Import

      + +

      Enter a comment in the box below; it will be added as a comment to +all affected tickets

      + +
      + + + +
      + +Indexer Log\n"; +echo "$filename
      \n"; +$mtime = filemtime($filename); +if ($mtime) { + echo "Modified: " . mtrack_date("@$mtime", true) . "
      "; +} + +$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) . "
      \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 "
      ";
      +  foreach ($lines as $line) {
      +    echo $line;
      +  }
      +  echo "
      "; +} +?> +
      + +
      +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"); + +?> +

      Projects

      +

      +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. +

      + 'new', + 'name' => 'My New Project', + 'shortname' => 'newproject', + 'ordinal' => 5, + 'notifyemail' => null + ); + } + echo "
      "; + + echo ""; + $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 "", + ""; + echo "", + ""; + echo "", + ""; + echo "", + ""; + echo "
      Name
      Short Name
      Sorting
      Group Email Address
      "; + + 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 "

      Components

      "; + echo "

      Associate component(s) with this project

      "; + 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 "

      Groups

      "; + echo "

      The following groups are associated with this project. You may assign permissions to groups to make it easier to manage groups of users.

      "; + + foreach (MTrackDB::q('select name from groups where project = ?', $pid) + as $row) { + echo "" + . htmlentities($row[0], ENT_QUOTES, 'utf-8') . '
      '; + } + + echo "New Group"; + } + + echo "

      Linked Repositories

      "; + if (count($repos)) { + echo "\n"; + } else { + echo "No linked repositories\n"; + } + echo "

      \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 ""; + echo ""; + + echo "
      "; +} else { +?> +

      +Select a project below to edit it, or click the "Add" button to create +a new project. +

      +\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 "", + "$name$sname", + "$email", + "\n"; + + } + echo "
      "; + + echo "
      "; + echo ""; + echo "
      "; +} + +mtrack_foot(); + diff --git a/admin/repo.php b/admin/repo.php new file mode 100644 index 00000000..44d6d995 --- /dev/null +++ b/admin/repo.php @@ -0,0 +1,282 @@ +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'); +?> +

      Repositories

      + +

      +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). +

      +

      +Listed below are the repositories that mtrack is configured to use. +The wiki 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. +

      +
        +$name\n"; + } + } + echo "
      "; + if (MTrackACL::hasAnyRights('Browser', 'create')) { + echo "Add new repo
      \n"; + } + mtrack_foot(); + exit; +} + +$repotypes = array(); +foreach (MTrackRepo::getAvailableSCMs() as $t => $r) { + $d = $r->getSCMMetaData(); + $repotypes[$t] = $d['name']; +} + +echo "
      "; + +if ($rid == 'new') { + MTrackACL::requireAnyRights('Browser', 'create'); +?> +

      Add new or existing Repository

      +

      + 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. +

      + +" . + "" . + ""; + echo "" . + "\n"; + echo "" . + "" . + "\n"; + echo "\n"; + echo "
      Name
      Type" . + mtrack_select_box("repo:type", $repotypes, null, true) . + "
      Path
      Description
      You may use WikiFormatting
      \n"; + echo "
      "; +} 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 "

      Repository: $name

      \n"; + echo "\n"; + + if (!$P->parent) { + /* not created/managed by us; some fields are editable */ + $name = ""; + $type = mtrack_select_box("repo:type", $repotypes, $type); + $path = ""; + } else { + $name = htmlentities($P->getBrowseRootName(), ENT_QUOTES, 'utf-8'); + } + + echo ""; + echo "\n"; + echo "\n"; + echo "\n"; + + echo "\n"; + echo "
      Name$name
      Type$type
      Path$path
      Description
      You may use WikiFormatting
      \n"; + echo "
      \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 "
      "; +} + +$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 <<Linked Projects +

      +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. +

      +

      +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. +

      +

      +The regex should just be the bare regex string--you must not enclose it in +regex delimiters. +

      +

      +You can remove a link by setting the regex to the empty string. +

      +HTML; + + echo ""; + echo "\n"; + + if ($rid != 'new') { + foreach ($P->getLinks() as $lid => $n) { + list($pid, $regex) = $n; + + $regex = htmlentities($regex, ENT_QUOTES, 'utf-8'); + echo "". + "\n"; + } + } + + if ($rid == 'new') { + $newre = '/'; + } else { + $newre = ''; + } + + echo "". + "\n"; + + echo "
      RegexProject
      " . + "" . mtrack_select_box("link:$lid:project", $projects, $pid) . + "
      " . + "" . mtrack_select_box("link:new:project", $projects) . + "Add new link
      "; +} + +echo "
      "; + +mtrack_foot(); + diff --git a/admin/user.php b/admin/user.php new file mode 100644 index 00000000..3000cce7 --- /dev/null +++ b/admin/user.php @@ -0,0 +1,76 @@ + +

      Users

      +"; + $find = htmlentities(trim($_GET['find']), ENT_QUOTES, 'utf-8'); +?> +

      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. +

      + + + +

      +Select a user below to edit them, or click the "Add" button to create +a new user. +

      + +\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 "", + "" . mtrack_username($uid, array('edit' => 1)) . "" . + "$name", + "$email", + "\n"; + +} +echo "
      "; +if ($offset > 0) { + echo "Previous "; +} +echo "Next"; +echo "

      "; + +echo "

      Add User

      "; +echo "
      "; +?> + +

      +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". +

      + + +
      +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/css/hyperlight/plain.css b/css/hyperlight/plain.css new file mode 100644 index 00000000..85236f31 --- /dev/null +++ b/css/hyperlight/plain.css @@ -0,0 +1,65 @@ +/* + * nice plain version.. - white background.. + */ + +.source-code.plain { + background: white; + color: #160296; /* deep blue */ + font-size: 8pt; + font-weight:550; +} + +.source-code.plain .comment { + color: #8e8d8e; /* light grey */ + font-style: italic; +} + +.source-code.plain .comment .todo { + color: #8e8d8e; + font-weight: bold; +} + +.source-code.plain .tag { + color: #115f13; /* deep green */ +} + +.source-code.plain .identifier { + color: #115f13; /* deep green */ + font-weight: bold; +} + +.source-code.plain .keyword { + color: #160296; /* deep blue */ + font-weight: bold; +} + +.source-code.plain .keyword.builtin { + color: #160296; /* deep blue */ + font-weight: bold; +} + +.source-code.plain .keyword.operator { + color: #160296; /* deep blue */ +} + +.source-code.plain .number { + color: #63c2f0; /* cyan */ +} + +.source-code.plain .string { + color: #b5249b; /* purple */ + font-weight: bold; +} + +/* what's this */ +.source-code.plain::-moz-selection, +.source-code.plain span::-moz-selection { + background: white; + color: #233322; +} + +.source-code.plain::selection, +.source-code.plain span::selection { + background: yellow; + color: black; +} diff --git a/css/hyperlight/vibrant-ink.css b/css/hyperlight/vibrant-ink.css new file mode 100644 index 00000000..aa2222df --- /dev/null +++ b/css/hyperlight/vibrant-ink.css @@ -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/css/hyperlight/wezterm.css b/css/hyperlight/wezterm.css new file mode 100644 index 00000000..f0e1af1e --- /dev/null +++ b/css/hyperlight/wezterm.css @@ -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/css/hyperlight/zenburn.css b/css/hyperlight/zenburn.css new file mode 100644 index 00000000..2f51d840 --- /dev/null +++ b/css/hyperlight/zenburn.css @@ -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/css/markitup/bold.png b/css/markitup/bold.png new file mode 100755 index 0000000000000000000000000000000000000000..889ae80e37b6167cc15f2a89e05a183815ec18b2 GIT binary patch literal 304 zcmV-00nh%4P)b^}|6b=Y6y(;Y{!a!g z@UQp#@Aw}>L3(}s|7f5BUjeuKZvQRjV<2U7yvu*H{aAbvQ6K!@3oKzW z-{Qa8d3gae1^)HE{~f^!v<1}u>;4xnKvUpW540I-w9J3a{{r=B3T*2g{_BH1CtaZO zpZ`6V0*V5g1e5i;`_=Z#_e=H*@8|93RG@lX;D!K7TKswwko8{x00005>jf6w4x#gTU%_MMNlkNp$oSbvBp&uHw9M;u0-4@=t5BI zP6Hx#-C_{5RMJ z0_P+Xkumexn8%)S+Y)#l(gR;YJP<6#1-=jjK0LONWPdJQIR8uK1HpvVIxBIQ2ztt+ zqoEx_X9S%QGMe=~(k#sebCL-an)%CR%a7YtUOQUgv+G>~?N~XSWhx=? z@$fx}0MB;$`JWcQ-Re{XV~5|{DvU(#*+NF*g)j^qk#b~G9_O!i*y&mZVZ=a3;Go(K z`DkskYn56Nhu+k@1Ke*uY|x zI&k6j$JfNe_a{GH%=n2rZOz$Z8R9V?Pe36hIk}jo+A-`;dt9vyvBu#Xm@veu&@v`| zzt%mwc_$nd0-sMVx2d)b0!MqGxmfCumx7yB#nIUWvA{!HOMfslMyW1iV&nY>zxwyj z8^JfLN|kT z4m^Q1mhO(_r4w@`V?H=YNkOf(i&bHT3Auc3bryK1_{hDSetLoLN{VLB^78ULiNFy^ zkUqqG$fjVkJj5tfWkOn|P5`HVEp5@-mGnc0wvJGHC=+39MC2TWT#i?t*~fNch*he_ zgtS^8dH$(KlW)EF1b4Fzv~?&0IQaNdg;W5&{t&Bmg9&N1-rBBr_;Rg8ekw^mn;@T# zlS{|Rq+-Nlg18i%UY;i|q1NnSwf>I@85#4U4002ovPDHLkV1mEDi4_0< literal 0 HcmV?d00001 diff --git a/css/markitup/h1.png b/css/markitup/h1.png new file mode 100755 index 0000000000000000000000000000000000000000..9c122e91e358860733eaf08fd543e5fc585d4cfd GIT binary patch literal 276 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^zbpD<_bdI{u9mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-$x=IP=XqH%ujg^j!|20W|*-XbO@ zAtBSd^khRzd{=pNv@vN)9uZDqGTXuVPf6#I=?;x2B`Y;1YQMI>>GxvE??vtn{c>{A z7MYxUVrui2JTF|YR&ldntL8M3{q7no52$4`vIX;r@S8@YTFOM5*~nV4Gk-UYI5L$} zDw(h9UDksmVjphbEsSQ?UdGUxU4Htk{EZoY&3@-Y5*_;Wwk`ZAkg@!FaC~ii{N>;; VD%(>GD}XL$@O1TaS?83{1OVXtVO9VD literal 0 HcmV?d00001 diff --git a/css/markitup/h2.png b/css/markitup/h2.png new file mode 100755 index 0000000000000000000000000000000000000000..fbd87657fbe001c0a78fb095284fffc32e739497 GIT binary patch literal 304 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^zbpD<_bdI{u9mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-$ROXGNhtQ`{C(%$wdeB3 zGTnLdz~IMJtNg?T>Z(s;oVU0)KW5x*9xvq)rPF;` zY|Fc4&#rLa>Txf#@y+aKf+ac0%`STzAI(*qdYo^XFH557y+_x*JpKO5^1S9?c^6}{ zP=+&OVHtDUrGNmdKI;Vst0KHFj AM*si- literal 0 HcmV?d00001 diff --git a/css/markitup/h3.png b/css/markitup/h3.png new file mode 100755 index 0000000000000000000000000000000000000000..c7836cf09e4565cc76c13bd14c13971c9e093c40 GIT binary patch literal 306 zcmV-20nPr2P)wEzGB literal 0 HcmV?d00001 diff --git a/css/markitup/h4.png b/css/markitup/h4.png new file mode 100755 index 0000000000000000000000000000000000000000..4e929eaf583f10cf50eb1666ff6530b9d4cc7915 GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^zbpD<_bdI{u9mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-&H?&;zfqH%ujg^j$;1_G=B-XbO( zon2btF4r#xDw?mmvtwaL3R_~+Yz66*rrQmiHySwec#9a-mn?F*9`VcnzTNDrbNY9( zxOA;CUb9S|sk-b7=Pc<<>lMA8+|AaBj(NoLhh2`-;+F zenR#-z6;(Syt?;Ub#DVE_OC literal 0 HcmV?d00001 diff --git a/css/markitup/h5.png b/css/markitup/h5.png new file mode 100755 index 0000000000000000000000000000000000000000..30cabebf7445e168a0f31b0ed68c43d54eaf017d GIT binary patch literal 304 zcmV-00nh%4P)ZVg9Hj*!Zw zxkAM~zCH&l><=6QeDdgV4l9hop+%GWq_IPV?Z641X8iiHrWJUN^2}hSiGjhsfbOLp z?d`9_MC0P3jVAVsEwEMMb0n zB0~XIzS#Ls)KFuA6ghUcX`bbD&-ZPgg&ebxsLQ0Hz~TmH+?% literal 0 HcmV?d00001 diff --git a/css/markitup/italic.png b/css/markitup/italic.png new file mode 100755 index 0000000000000000000000000000000000000000..8482ac8cb1eb8bc8edbf64085108f0fbd204fadb GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^zbpD<_bdI{u9mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-$B^mK6y(Kw&{<9vg>Q9!g~ne(gm zmj4swoA@7?D86%i^8WzK9JM17E&sp&Z#dpHfz$E-U9ks&4?Z9Gyg!%0k2Q{M-Tz#> z2OnD>vrPZ*#{EHKLq)>Jcx{H|Ovdb&|4aQZWSipI{El%e^Cxx{^9vSw28s;a3IDB= TS1%U=TF&6<>gTe~DWM4fm>N^1 literal 0 HcmV?d00001 diff --git a/css/markitup/link.png b/css/markitup/link.png new file mode 100755 index 0000000000000000000000000000000000000000..25eacb7c2524142262d68bf729c5e2b61adfd6d4 GIT binary patch literal 343 zcmV-d0jU0oP)$`dXYaZs9=SbAto%g@>T~?_bH&lTUn@`uo|1bXE{eSR(AO)ESb=V4`uk}mK|39Px&03WLbv~pzk+s7D@lK^ zn+aB+sp)&Y_x-B3>;6ywU--WQNUr<8>TU0P-|L#1U&;A)67w(+> pDf@fM7q9#F25QXo3rUI;002ro52U44e~JJA002ovPDHLkV1l;_q@Mr) literal 0 HcmV?d00001 diff --git a/css/markitup/list-bullet.png b/css/markitup/list-bullet.png new file mode 100755 index 0000000000000000000000000000000000000000..4a8672bde48f806d3d4d37db192588a9aa3eac10 GIT binary patch literal 344 zcmV-e0jK_nP)PbXFR5;6H z`2YVu10|S&DhA}te_Swi*Xsu$nk)lAnzx?+_#Z@r_~qs0*+Bfiq@?73K|#U)?Ck9S zsi~>|6A}{sM@B~e4-O9gPhA%bd?2RGd{of6>E(lvp1b6Ep>6&12TPB<{a?EDDL4@0 z;^ML+A|n0=1_u83^78uc?CkvC#>VEqiHXU7U0vP(YHDhzff&$ntDt1@;|H#l*M@2! z+U8#>h@W=vfpy*`^1J}j+`sMRe-I7g8yOj8Yin!&S5Z;VicFNp}SURRVGD{CSNFe~ni^^#wyl5uzj4je z|23%2?k#{x(*%mqe9M%mih+W%ElRQ}7!$^91> z7ymCLB=nz$hvz>#JNtiTW@gkt1Zf0eyP_){bPq%T_kY#2Z7&xs00000NkvXXu0mjf DNYA0= literal 0 HcmV?d00001 diff --git a/css/markitup/markitup-simple.css b/css/markitup/markitup-simple.css new file mode 100755 index 00000000..85904d35 --- /dev/null +++ b/css/markitup/markitup-simple.css @@ -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/css/markitup/menu.png b/css/markitup/menu.png new file mode 100755 index 0000000000000000000000000000000000000000..44a07afd30f499cdba30847094a1e92f13e1320e GIT binary patch literal 27151 zcmb@uby!=^voH>&K#@Wz#kHjZ#i7NeKyfI=U0STTJ0Vbtw8dQ$C`DS_-AZu@9z1yP z;DID0FQ0qw_ul(^|9Ic`xzBH(J!dvMJ9~C!&(6-AIVWG=zf-U&MM|6AsRp$5n7ToocSJk_Bn}YAzMBn}yzS-~r2X-X~Pg_4XPbV*SvHDMw{Bi(&-4O^6FCut;Y6b0D#;nkADCyhU-M0NFxfj4cv3?psgw0C*dj%d|)lI1h@ztu& zMbd4Jii?xH5^7}#dt1KyuNMDP$n{e=9}ka((#1(n_jZitrhbb`Nqsi!b4j-I-@Jr^ z#yXC3q+8EY$X)LaDr-M_)|>J-A%z#-OMI6ko7#A2C(Ymg3h0OHHP9D*lQ)5fAKf^Y z;QTteue|d&_IFDQqg04~C!45bP(iiGE+$xr(bTosz++@EzzM=qn%r3C?=|h-pCuwL zwWmElk7(KWCbR1eQF?DK8%ox6F6_`~g4_sX!X6x6y7mo4P8u?A@P5C}J zF(^2Vx$nrAucR9ix{!EE%D|z$Y){E+7RJ0uNgOgVdPP_7$yAf_}8@Rh8|OF>H8i3%)Vc8iqyV)?{~@s zsxp55an_BT7fRG-i;ijGv;leAP^37GXTVY@-}{x4Q&$Tzi1gfb*5eY1qyD3$InG!- zR3&&m5?GK)&YInAE$siB9P~20SYo#U7}k3JVwsPBu{hLZK9VJvgC$gwxl4MBv}Kv5 z>s~HJi}mpUdxrZ5d0qY&`3}WzbjRzSz9Q7Dg_k4<(Dv^iRD7NiVGA9>-aLh@)2X2p`N|irxPxg2^#m_a?>_EPu)0B7b%fktFm5jpgyng&B z`TZm`C9JNw;9UNjPB_YhP2)q2NICaA)RM=nLbsGcFRcGfIsMPcq0$C=A%#%OroCeE;fwSw0n;NO&Z76$e@5N>elO-UTt| zJPXs+GY+o9@WD5ZuPF*wv>2tnzqezQffMOg77}=6hc^?G`pI?wLWVT0C67C4bIJa> zO!Z7<&RbhH5Z000&vLXwG}@*% zde0v5W*WyN(|q{TR_}x@!X5jaVh*X7cN2{5@Eibgjcq&NApy@MiI=~7otK};ldsx% z6ueQZ$IJWCvgJ43#!^R657^5~|LRyYvt4R`#=^&^1nF3#Z^t)v=Tj-dabB3|PEtkYAI}!h)?46{X9+Tm9_)iJGG*iL zSwhd05|%Pd*In3aM)*%eBr+d|FjHjMhNV0Ajhx*5Sr;d ze-wlnpKQh$74HrNrUr4n;#B>bmOngRLvKQ>F#ORxlZxH-+&rJVAa4Q9d_R zDJ;*SANeshUxm*EPw^t3GmBiY=%Wt3$GZm})70)fX99Rl`aExSHdH)&Yn!%>?U*yL zBRRxH-0@Pq+_v#WQ7l_fo_4WRgQ7FgmZNGe!+js%5k{FP5>-WP^Wu|3#^F~(t@y{; zDL=xfd8K1!RPXda^L{?t%5b}%y8ntao6xFUN`pEnu~pvdl(l@|QSkEjfxoTsgL5oN zOxUp24Yq-s=CrZKwR!8t-p#bYmeUMh%QbUzff?pU+R zRhul|dnFnco@880?Yr{TZxdrI!0;LWk~~tB{c%KGh9z&rR8){dYNHwoHOkq6j)dC?rME_pTeglmw`TOAM-vNp)VTPA-{@w^W`*e zJo1&#Y<#OG@&4vu5L50{s7@)l(_?~H(tW!`pg?^_nICHt%v6_sxsh~|FN=wdjyxD@ zJYE!z_C1@m3m5!8yyxla)3daEz5JKq2@&z9o9g~1^kWIoW=0){_k)3z zy{&G6k8*6RT?Zt}1!jJ$RqISGvaT<38UVa}S4SH+K4 zL9Hq+_EhbQE3w3L%qOt36s;_Mq`u!qKGDJ8DJ^|CydLzNZ=9Xn7GJa3@2}u%n+ys4 zX7S3CYWrB?NGDn*<;020b?1v;RLFH#`2$YYzrQN4`9mk3YjGJ%4+q#+3mZM!rXo}- zVzBe<3QGF@;>hjvMzaAHl~m>v!L(00^`25kXIx=h%y$34jRaz||FB{F*EXx}u3b|j zvhbJhXE0YipNDIZS&FpO_hJ1+%wR@vjd18*68QzoeP5{;*dU5^C(k*Sp&uRj`T+Wc zzlCp~Wv%r}-E5MmIE=)NrWfZ@3)vCt<_Lh3_#fRb%*gD^U5aLsf>Epe$%ujle2UP1 z3ICkXsG7xcd|vuNS+?K}t4A2U8b^PQ?K4eJo2+}ks4m5O81Dl_MHd3~AGS!?ywj!4 z5h)f6`^K68yABfisPi4n2c8bs$kx0QH@)tpkm)m-MmnE{&t3y3hd{^)d^X_#55U#K>3@gz5oOl@nJktS8^la$#7rz+*o!YhQ%NBiF)mI zfGAMg{wEUjnXKt1p9&1__ivm3nn}>|yJyT4mvhIKqNOPzkTDhRYAbNiH%-H6#3d{{ zI8;;)!MyLIiRG2WJC;K58r#DgvCjtI85RaW=rc&!E9^<=`yF__#9Ak`uX*k^65PYe zY1coDh!e6_X?A2V5V#V{sIyQWp}-ry~k}x|`EG>z?_FfnBrcn`Y7bQNW$@ zd57`GxfG?6HofAs$#(9K`dOp-uX?Gnk5#&}YUi?ov z5+HP&PFg#9?B6CjuxuH=H%0i+jD|z;FoUeES5(eybtm_EG%vr=q=q~XdpTOVj?xeF6eI6yyxB0iHH&Y< z{E4?ubA@bj@zVCGOItHG)tFP5NQS#^W55T~^r(UiAY-B?q#ZNc%_0c9NYGwXgw)YYi$ zGo;VTSf-5(@HAX+@!t^0=9a6_$psQP!7&7O;ko?DrAFy2q?m}J)u&NF6n3xw)2Rb!o% z03A`&{y*u4BWd5%&5tX9W5!Pc!(5wl}b&-!fI+`R=1TEM%KWzZf%Wiht>A zs8dUXuK9&x^Y&7DQuRA$J}7HA^$mLdQl;{jZ!@j^$HIzrf{NOg32o!qZ0^}aYj#Y|6M(NW`V!%}+Y+TjJL=Jz zGM_ki6niu5siR{x?XF&d44+f#^#*wza~wu|ZyA*O+=(9TyYcU2$bd3G98pdgjN#b5 zbpeV+a{|vkDgec!%a`6=E-s@ztlijfZBNn`4b4pIV(pIZ5m`6`mZO0Rs@90t%ondk z!>p|(~oNw+>erc4p9Q-j{HJ_#v$U61H-Gg`2}FYxXYyUiuw1` zvc*4ABD^lwep}t&>BXQq@XLh^C3kxl7&a+x@k9-6oj*}|-Ibw?SVdglk zY_EN_#?!Fy{O6P|YSmua084i4*S!4&(W>dR4}^zv91w;NTr;_MFkZcWWY~3@&?Iq9 z>bh#!_D8qn520vW4V*HEH)pMJSd2jcceTC?wr1bsm{xUcoCdc13}~#*s@~v@+*G#ccP#VYjszCXRrYe=C{!?{ z5ZF^L@{opE}Xbc~JUM>gf@;%dn>H7B^ zYfN$b1B{Ej6wGd zp&^}F?)TDGce)PRj(@8jn|ZQNYi925(>*h2cH@##dVTVWtg?##7w=rF6+*GL(W1() zdxbE_{v$$jY^C!(KZdXZ&5F|Qo+UKp&nh4NTu#=&Hz#3e_OxsEpWfTuD?~^u_?lW) z9Z3w;y;0vsU{jTD9puKgcmBn+e=diHm4E*S zk~NIc(Ov&_r>l6$-k+=n!aT))Pmuz_`FjF7PB88W~BBrOQp5H@mN4;hF{!v!BRd*ep zPfH=}1Wr%=%R6$XzC6o#aa?CVqT|_K_OlVDN&{F!a|dC0!cMl#8e>$V<>mxL+Is#0 zN~O*IrP6a;Tgb1uIDK@XV#g_Xf`8^qhxt|E33)rg#Q~RWl12JX8*KaU3`-MjKs#96 zaQ`0x-wK9G!AQukz&9ltKf|K+j-f22YVF%YYgnWo75%Uap@Db$H+uPZ*}B_lf1fdZ zPAj_8`J5Rs&rHy8EU9pkDScR(lale?>SD#YzA zTY>i_REtS%63NmI7k&< zQyG5pD!C&}qWGQ63k-jW`C|*ckiW2K-?7=M4jqC6maFgBtptotaxjTjj{xl#)K=0; zwMDFQ!Sg4zRpp&TUoTWqhHda=4T|HSMaqSC^htmdWoOb3FZc5)Kw$I8<$B*!Pfg69q5umo zGSDRY<8Xill4~9;DIXkSm}`w2`U#U12<&RW5QtGq?@CTmw^7y{1fK&|_gg!BQCODT zwfXp`kN%)gVNRi%nL`7aje-VV#0l{h!lRk|1FY3l!bnn`+(trsv+-&fHqwPBY^LRE z#GO&C7Rn|l%896~WD$uD5l5%uSfH-S6;1xnc*8IJs739kkq>j19+QETEkVhkyOrSW zGJS_((e4X8@wyLqNxb1opUnQqj?=s1tz^>km!T{e90}r%h>k6vnVUq4NRiZ?M?Nv$ z55*Dfl!zV;AgI=^2LEyrJswpWXJj`@Q<1a$^+;sxEPCPlJTp)wa7ARbEBJZT(NLcX zA_M$r5&j1ryecgkJZq_H*h{q$in??el9VQ624ne{-p6wWf9YJms81

      +35?Hj$O) z2JCMj9zju?tEyh`;6@iN+FC>%gS>oOR0Ayr^JovjDPut@bP+CYawfiDCK-o) zx4QE%HM(2*$P4!!bCLbIk!*#}zjgKS_2%K|E^CP`#=m;gI1xYASMwV{@)g}jUC>+2 z44UqVT9%>0ya0zGcV^NU9H;L)6A{T1`m!#!SKt_twPgOOPcq#~R%6Wlzr&5nji;B4 z;^Y1rM?c)P{n|I-=NzcFjTL7jRt?@JXz+fb4D!hkQk z&OggEP90d;px;c1P7KzlB;uj*gagko~;y(+L z&Aj)FMPj{KI5o3wNCLHOvrIM8!FBYz!Q6ErZ)-jT5bNGedz@K$!+hs?`Yv3mT_Ic| z{UCWasCtEGL?WQtatfUpCTH;ZglsFcZJVj(v!)JQd4y}I0L5|7`mhSP_JOiIgstlX zXDkj#C(&2r9|E?%h&zh*O7p|=U>nDOuzk(n0G6k!0J1l?N7bav)@>7vQvHYTyydA; zI%#6QI>Zs+#H-lUM@Aey+E)AUuUSnUeWI-pWRnCwTA<%rZ3#d45@ z=&PB+9gM%`v1`Y7Y0;1svmC;BN5!apfiddQaN>mU`$JPggG77YH!mFs$Nr|Ma#uCB z{-h(Q$$BmruCB4lqsZMrVB*2jWo4Xo?6OnwLGyvVO~;)te_D=(E}dh5b3=*)rC3sN zzsTp#T%DYTkCX$NXs53jIc*4r!Cq5?4>wRHG9CmqQHs_HGAEza3vM5+vhGmxD%>uj zrZpIIgzauO87|J)$4WWS&f?nbN{2sDqw?AHjWIv~5DTbcIre4=98=G#1;=TreQSM- zI1q%$wH6(~)MeEVV+{TVynZGdB;W=nSMM{k9>fs4aev?Z*ol$sB`K;=8=5oH;0LGz zJYL58J^Ij+e=&b{d?&erf8%pa7a{CL4luh8^fZF+TTtyjNP#gTk#wZb)`P4;?FD0F zNtqr}JfFg>7T_~ZsQ9Bz?HZlRVLfY-m29Q8fq2>s0V zmGo!(R-HLVQLX2#o5d+bubagKRCd<)?wo&R{zm_6lLhzYHGnnZ^&30J*hZsmi9ltA z)9Uf>7HJw-RUYsC;f=U;e|i9`s`7h&m1F_v*`)GImhAQvP(Z)BAkuH%*fpM}!9Xa- z@TgsKEdjK(Cv3^YU_mH=6Xh{Z^s=jroBkqfS04AaQf!~`zK2Dr5!;~M{T4p zV$EL!OXO~ssFyQ?6uJsWpZ+Z~%i{jHp&g|h?-bVdwxLFcaNF!~Xvkfijx9cr6tcF= zFY)A$%zAGo`5Fs5)8Ajj0A%p3#PQ6$$Xc$9LJ7=3QX2iE4(ANE5o&^|CW*C+w z9kS{`?5O^}9F-#Gh^F0|rGM2L1Dv<#A+B>1r~3OTD4i;G;7M{GG^!RbVo{+cQeSuh z{pnko9Rnh}X5=BJLNv9==vgGa&Yiim9oTzj`=`2%Jy`2+CIX$)~cO;+0QjnpwF-ER(xcEeZxX z)`GjaP(=ShUwoH?J!vq>Xn>Dw$W)~D@4d~0^!6iucWD}txICKng1%2?9>hiJXI|}R z+rfOV=4IVr@!9>-&VMXQ!xdW0+t;JJW$U8_MAo*YB>-5CzeG;)tOQYO%GepK^ZLbu024rmb#rXiF9=Q-}mPF;HkP zOX3`-(;Bk6`DIToiUpxPA@)S&(qvBc^5k1kr8_QUpWb`_ste{3vhY*Xr0R3~@aOiy zKRE)n)-85I`lXSt%iY9xtS)`!EUwN__p(m2mYK31LchJRA?L@C0C~^PO-`pE9kG3G zwOmWv_c4dHrVhVCzb!d>jyo*HQ@(>xm4#0=>Upmve~`OQON}YwL4^r3TG3K4de?^c zahK`nV<^EC+M`UBCrZWUKXRvz__!u;1YA_>##d|7}M^2iD>xDoHMLp=PSZ7JiXfSkpneNS~OAM`hyJe zifAp3!pz~kAoO?J>`s%bQcwbIL!o`b&KJ2vZ=$Ihdgmp*KZ#;Pbbmw{;v3dX&!AlhnwDyVfUlqjX zxnkp}iE*4an{&f=ij4$L-LG<4C-{PoESsY5KQhF_Bdx#XMGrN08#)FWw){^+#{Zq} zHe{R|o46=OuB5BNsQQ@eY3$F0M@dRLDy|9HerHEyDuc$AEQ|iapqag>pZ6aAz)L_~ zd(lUJ_|NRG+5yC27ZLsOY&e$I7y5BQ>1*@X$7?-qot2iI*x$&74*MHJ@!#Y<=&&}( z`sHyNwz;(-ZpQ5eutM4JevnKEW^Q&y4SR%h2BOY)p)faC#|`FmYZ!?7aY&L%`~Qyy4Pp6FFigEZdkf4wTxArUEq zH)Jg>crZVRE5mAC`yt480yZsxrM&FWg$ACR5u-s^Al?o-739-1?xlIn0Zr03-2H$%5h;UwzNlc85^%~QKDS7&7lE2 z={0383+#cBf`fZm{~=3%Mf!eNvT_x)7 z4&XWGWOMdL>>LNagm7(m9cA*ReIv^PIZ_^!Nqc&9FrR5L1oJsB*$At^5%_#+U+r*_ zjRORk$BgrZ3;@P`Es;1i$$fvg+lal|<^H*`dD2SlKJZP$E&<;`DOcv2n+H%K4bzz{ z3xLVI=|>xZyT)J{r!ROhc$-s$IL>y%L|MosD!Zn-?fZG}A{`vHvP*RSb&;f{w}svp z+n3S_+27R48Ol7@!RA1(d$7I-rs}e$a)3fv-W$Km!>#&hmx-Cg8-SGZfc2L8m()%{ zKy+KN&(?|mwnpGe5i6@cwnu>cpZwn1^dK7}CDUndCVd&oVBN{q063}D?D$UBP6O=0 z^w`_mPwin=XGwM_`oF-Jy3Tp!E%wixEbV1SPV*|2WOE8?OWAR$%TQl|;NZEqai%$- zbbql*=li~+MSHl{LE{F0|0lMqx~{oXfAA#~87#8WuR)uI>>)_^eCzM^aicnjK3 zKZ6`Jp1t0Q-o?R<27!e*L*L``!eT8JCa%LL=xS?$G3H@kpY z>JFG`ajB2)DsYprbnxnqWq2;8pY94{ri!x<)t55ab zk7WJ;d?@Su7ipoekqV>obt*5LYv-s~y%mNnI^l>5S0^kbYyl-+WNK*s<8)kR<9uxm z?0(+psLq|8v*iYSVta~`;@cPkS!H+cS#Symj+{a+cYH{}-9^re+#;^OQA{snw0v!sYe2E1(?;Kh645iXcl=9)j)1RTE`F{|0&|P&HZj z!ZwAtfU=T?E`6RCuX8ahZkyJZY_(vlYT$t^sv{#lQR@g9=#PvEYJNV~{}>%%+Z@P; z{e@}0$;C|3P4_;TsGKK{ef(43;?IJg<;UG|z#eGw`GZjNtxuJf5S2mk77Bm_(KM1oOK#-xclv9C; zf@uLNVs!sBry>OO;ox60R+VMyQ`eczeJpyV z>+4b0gE-ipEx!yHi-41|w9Br1P6WmS=o|94au|)S;eLm`4T%fBBeJlqD%h`M6D*x1P!7jk+n+S2Xog>U_Uguy_GEjz60_@r?yh{TowKT<9z69>Ocvo4w z9P}v2Y0;|*HIlC$^xu=Jk%d2$a{UoMQR(^^_eVw=315~vZ4>DSN?vdDm!5lJ zkUQ9=|J<&ER{e)#GR=B{E8mvUd+sd$@CI1{==U}{4%}-HZ1pN|yMH1+Gu*n{IvGqBwsUt<>sI|l-=%U1~f|6KC_p%edIGYYiv{e4fH9zuDS4f}F-Oz@4e z**ds$b4h*Zc;x}iQ{4!cQST)LT(9PY6`sOd;?(vJ8fja^QsQD>$gC$By;)CdqZrdJ zLu=nKek@pB@HmglJ@xbdN_OxO$(7u>f7E0-q#m3apXfVA$G);82#=f5Y!i9lbk7!S z%PZ8MjX=G=28*YkvB;`h#3{D?4EZX43aQpcR96^RK$V)s()NK=l<#D>LL?=m)uhr9 zi!f|H?}0boNz31)fOm2$O#Qp|tyl$Y9EsT)`rXaC(UAIHx);}rT?=)by;;aQTnLaY zatN5N?QC2_a5b|oxteRWqCHnwESN@+wT@9Ckxv?rg4o=*o7b;}#Qj1=QkZA>$N8AS zfxNzl5@_z5eq6y>jb_k_VLIzgJX+1TxzYq#KD1Z=!|GXM%>Bd3pU&d(5p56QsfVD3 zOy-$N2!|F#KahK;TOTJYd2~a4?G*=KvdIcz^*vdA*O(pkHWx1XlFt}k%Eav}F%@7d zd444}3@8fO#VrR%G}dtUB(6lf!-@k7R+qZ~sX_96AP#S$%H0g8|53umOu1LQ(O{=j zWsGhao55i=hJOa$>Z2y@wnwgf(|IiQeWq381+JjxOrz{nf^ze%d5y@DW0_rU=Bd3# z#{S*FT>eV#xpvc_xJ(#Ji|6sO!ntOJG1Q|dWNpW^l{qLr;Cov^#p!4OBZ)QPK%iV_ z@ydDYdE$!X)R7N^^pkZeUTBRcA?BL8G>Vv;94lnOBihZ^7Spgm% z1@ylR*rc%kZixO*%l~e`GINnfutZaPuv7f~D)xYQ{Vnr=#qORAwAEhq4FrNUs&sU; zZ*_R^@ZNYhC_TN~N`Ri6DEwbwRThJ~*x!Ofz`)>j3FL41x>sQtp6wN~8>hW{{S7M| zjKkh+O%-eX8(Jv%`g98h;p+gPFC`IGqPBO>(aK8G=sHj;Nb92Wj>rVI;?nX$8|9l? zlngzDUFVwXotAChHD_$MC>*Y^KWqz zD>CMuiPZrVWqvBt4gA{GJG#mvTc{agf$FjSw&w+(0H9dZ2u&FVXka&s+Xd#fg^ghM z+)8u}{0^7{4FD5->*4(4G9@8lQU;#BEpKFo9?K$W^mA{n6ClGd1{^|=uBjKhBZcC) zINB`4rrrQp*1H2cihOweVEv6VGFdGhPPuh+_+86 zdU-F4S)5uCoA+%l4S`XJQ4}X&`6hl(p{zY1ymFcQB#7ENdI-d+rsj%+eAVPnZ67hJ$37rZu-J4#(VY((7Oow zW4-ma&zlVCXskKwag#`7oo_449 zSlke67B$*1C*5mysNH~h-t~I=;w9K%p6$r!nq%vcG;Ty`VCe?$xves$_*Dk$+R;^@ zm%69GDL`)U>@HVr zg;vIF35K`~Y=vL=RiwZZ?Un5_uhD6ezVV)Q5TtL2H^_r+qYM*-jy9|+xkM;NaD?j zODg*sTP`c@tyL;f!yq`rkwbef@>|eR`uT`lkuYfYTorgG7TN!)_SElm$Yx~H2kJLG zaNZQI&#a2vG=zJkgddBQAI(7Fss%E{x!mamCvUb6QYhUs&zZG*!E0v&%GOwu^xv4= zQ?tI`BYUX0w!cjJebi+Ku-wMG<|C8W{8ti(xSb)0unk;tuvEfcUVK0&H+JA05v*(& zlA3-5^yJ-_eX~C}u-pa0ovE%-#lv&~<2P}TNi3*$z4lbPGPm}N ze&);tOMtL6h@A24yrr=(y|nJIm-IS2k{xy zA=XhGda?y4+!gzvfJ;PBoA&h|o_HRMTn{A{exqKruUHG;>@R!jHK;J)aG+`Zj@J8Y z;6Xfji0iYBPx{0w%C9Jj;a8lN=m;;@U_*eO{d0c{>;3a&9;L&Zh=%@anyQ|O@+OMY z((`)mjc;U9-9tDIZUFy=zMq)1&ITUEA?(9(!kL*oVuljidQ} z(doHjtP#4u;3gUO;JOMtCRl4Y&HVsam}S*eKKQ= zU$5AE>u7mo)>RAz=qTUt=5zayb5g3CQKCPUy`Q3bQMmNdVlKa?)1vaA^2B37+U22i zK(PAR>6LG9sV(N~qS;M`=fJnzSNQedem3d2?2{nFy(ZMM;3OJ-H&;@6xpC{%pLt=Q z+8r(F_C^MkoidQIOQdHu9e2acs!*LwB>fVRIanH;(W!i_#-i1vFtkNrXnvhW;~yu; zSDT5b&!ZH#R0NM#|60G_eq(Us(T~Ny01j<%`A%}eOE&U`5&@8dPE+NryC>HyekD2J zqyCmVEQ{wGLA>kXZs+aHOS&vJ&{WG|5Mm4(K7_pz+L~sObkKVa_In8%^Fu{7W+qDz zO%t^g_xIaF%Nvq)v0C{X#9ku@D~|EYdo74(Cb6a=mcx}cyR#LIKTm47b8}`-&YMU4 z%;#~U|1rr|t$F+O=8z>P*7-x#7@vd=ZrfrF^U{ZivDwcEd^b6ZczD&3q_nb5eywZ{ z-mdRKWi{=rq!g0&Jm7O9yBlr%(|1RLOz^sBX$e^2`y+!58hnrsxIn6|pIz#CDW-Ez zSiLlWTH#j{JwM&`tF!`U&wefk>3Zx-{@}4Viv;sw?dEsmxq=>X%FXv;3;kqznuE4d z*T|%leqWrO#g26wV8Na>LhY{eE6#4Xx!Ael{@`kt2Dj{nJ1^EfJ|2`G{+RkceB_yH zWeYsmv$dNbvR8W^;Iu|YBB#jH;5LL4q?MrA#wG$9S6z~heVcCdtbR!OV8=${M*;Qfj8wIj@ zjVl1T$FvY{(W0H2kD}b_WhH(x&kR@G3@Y?f)Hfgb%?}a-D=^^UeHqy)bzjJqgj@sm z44^2{I51{e>dMR=G=cTO^++F7o^|dmdFIt-BRxiwJ4R~FWn^%}HWL7P?)EDTWQ#Fa zqb=fMef2`%64xq-!Tw{S9z{6cG=8%UZ9K2IjtHolv9VUH0& zD&+(M&#~WAgX*L*SH|QC-?dZD8qb=RKe(of*Kr7;U+vMll7>y+hj30YmJsy` zNXl8cm^?r6a(C{h>m5bNOdebMi|^ulezHJy$w_~SQ-7x87DB1CgWxv}l#AB2ex&!8*bMV z<4edk@Jjw7y(Rt*SkL22g)RmN#Sye(DPSqPQrSI-D`2_lbBHtsXorh44UWViDKC&- zRe?wE;am*LJ6k*R-M;}rCeVm6;nLbnSJ7TaES)>r@T_yp&u+{6649jC=?A%nnfA9s zCBu;s)i^6>*?(!xe{FLA_!Gy-HL9SAdGvW=cL+7E?6SSKe-h?EW#y)E8Sq8M$L&`S z%)!j#>w)@NFaUQCfj3Es76lFJ`ud2EDkBe1ejQYHTL04oel{3SpK|>EyIb;RiNBPr$ zZ2X1n4yUhx5LC=j^-OTRW>O^Lg&2c^9v!wKzs!xh#MBUk`aE(O(|#4HpSk%9fzZyp zxbb{(iEKYIg9FQ7z>~al^o*Mze&^pt0{Pya6-t*Z83s=chImL@=6B!){O-_fT{?l! z&*P8wBhxB3`{SLk_};~ckGj%A+vwoJ$!bVI-Ma-#fL7&^E%Z8NJF$H+{&Mx^*jr;Y zM^^;wVWogO9C2i}yQvdS;~3sus=9RoW(3J_yN4==l~{9RRxb74xmL zvLP%+OR_`g#hZ?UjW;3Q%1mPziI!5#Z+OtwvQO9$c25-FMesY#PNudw?7d+wn*p3j z2(4QYw-sm(DGUTpda>o+Bn~b@g~xl%ec`l;N57Yr{rx6E=gSfc=`sre-)?6;|AaF7 zspF4*zjoT&FZu%h%f!gmqNB$3&*=t=L?W^K`%Y>Ru<#nzbPe>wT}xek@UpAu*4ddC4uwK_ zg$(-?v+B?C>URJG0>tNTt)0s9a~v9t=2!D63zRLuIiMk{xJ(%tnUDoMFMOAR3ho%+ zZl8#m#4DPycH=Cc5wY4jRISfDhDq6w-Vgmg`ye>Zyk2&}vZTsG>3JskQUa8INfZSTDzzNu^b<0@cGWwLH$`xDOf)1D1WmLeX;|0u`A^Pjj})`Q*w`FA!{JT(=}i~*TsS(99va@m$ zSp)uqbZZM-<74FG2SE%zBlSad`bG0+jPs2+YPp<=n?#(lt&B1NE8B}>0A6AfeAjc@ z8RD*laf3$GA&%{+ob;eOSAlxP=W>l&7Le1SstFltWUZ`MZb1A{a7Dv05T?upeqSWT zFE}e0B9t}(f<}UM`2Wz0Ud7`vO?5o*azO&wygcJiGjP|yCuf`&=w)235V+JD3u$JN zJP%3o;k`KF0)%%)4@))ZUkSF_(>HA1NWeezxvzhO*S!wPy%I)p#;1k@olYLRyl@!; z%@5!TgurKf10klfTmWU*Sj)%w2_wZNjY>H5W**(0<^CsaFZIK8c=J!RDExFnlpcbF zD+OE#EWCkz*I=TSJp-jSBkR>aIkdl#?l|xKj|KT}tI}LklIs8Wlqk8$7L^|7I4>EO zgNKK&`(H|^Fyg-}lmBV?Rte3nIfPq1iqt0dpd$8oK&OJ#a>}6B~PE*{~mEAifXY3d{wd`{p}aET(RTy*dz< zI4C?IWI+~sfCJbr1|C5Xiw!rZ5hT3xrn3^o3PqM8-&D-_p>q^xz${Rw{~Q_$`OjD5 z|8ME)jV$SPS(DDf%YQ!ep7}5FHuU_zb?|>~bt~S(Jyk+eR` zw-1yB443XAGz(LBE8;oQ=pD;3MvRc^#-rC`M)ipPef&eo)NO0Dl|Ksof4;*14cp>P z5L3p8%hP@Blx2UW6) zD(Jc6Zr)4iga?ZV={ftzvKmJE0Kb8hX&m&l+iy8}zWPh|$!M?_=0w2_FEx8vj(f&K zxS_*Yi{{a)f9{(#&Rv1y56zX_vPhDLc}sdcKP_VkDn-MZyv%YJS0EV{^3H->7zL`c z)JaN8?rEn*&LgFOcTdDF(rn%gu61mCPF0{K#t0<=(f#oWC41_H&=v&d^Y9cD|l@k{#Ar$ z7S+_-)&1Fp0>pCHM=S3NQ6lbcb~SY)#m_{^FWZJ`YK;~iI_gF(8&?;$yVz#w#x{@=PtYgRdd0=>?eHntF5_Z` zWTMF^Qf6p~x#Z!n;&AWj1{Y1*v=k-JuE?d=tKPLP;Oy({z}1h(vOj;Qu^q2onlVW+ zXfbO()8gP1-4t1sD5jMr$^E@|GxeR=Sgrv1CtT%zL+QN)jXW=r>@fn(aHG9}Yx&zh z19;w_q1I0Aeb=^?Z|L$NrUXJF%t!BN%b5a^otBc#Yl5GFO7Et^nb+oFoq1b za6`goE~V!EC%@6aK0C`QSd~X=vnds@s&q!7?Pxyk_RRGChlv~fXptLwqldh zzhxhk7`uJK3snNqVFW}(*TTx#MAVc{(v?k*BMAL`xw0UlvOLV2Z~Wp&V~JcfSNn^W zsa-}0hn!?&FLnks790_jwKUS9%ajYx{K8G59Rii~{E5OIoPT*r6;|o={!%Pku`$8r z^oqn$PAl_d8j4y8Ed?>lgkX~*x0Y7tA`@GPcICxU_3I>&WV1i-i0PM?sxwn4^!U~} ze%z)4E6D6i?LCT}L2w+mBoTo4u2(s|(t`~aZBABo z)$$``kd*XyXdy1JDm@ zBP$0eEBiso{=|bf&LiSWe6xwTbicN)$YNj)0ekpwu*j(rHHC;@q}sXOPz`qNGpyq2i`yu`)J5%-%{_lzg3@n)ZLfy?_^*)jGZ&dD|Jzph}IVC8x5I#PL^+T&OB z`QcGyMgor2dKoz`d~VM4m@+LQ+CZP;jhK`KL|yv7BGF5g2BZ?V3i)rFPFI(O z3`!@h)5qyi9=m4p8>t z5J&IWfvrzR#d#xSxb7nO9H+&*4>`+6?Lz)K9_7~ zaM39xMw;b$cuX7^^l|MfWDx8iWs#{8a`0^be09O9foB?FF;J+)-^v^p(ZN)~4&3j6 zy?ce~Mn}E&8r?)tlB6E4M-7n$F>%^4x+rOJPFNB@GwxYc^#b}fm}h@+!czEYg%2mT zp+h3m>Y`b1LSWzDeCMF5+}}2N?evJAP8Phc}vI0FLK& zGtIMZQ6ZyH0PyW__@YnoE>gIMP>ChmsSWC=qr1?p^UTbAa5f0k*dBxhzb#@itFtrfa=~7tvBoWUlKoHqOGQY-T z`$YRZ)Cp^sRt2;qTTL+j*(WCsTI(cK&quCkq>;`rrJFcrMJk;ZJEJ?}aOj8A5+gr* z{16&SLG*tq{qe81Wrs&WHA(orfAbTiQH@{Dqp+Z&;lXrM`@OIBJXyXk!$v3s>$L}& za_~_qgwGrOCAm@a!$YAjeI8QV9j~p6q zUAFCtS(37i-ygr?l*2O1eK>lcc*P252(Gv8Ixbk^j=BfPH?qEV)pdb(d82~y1& z{%?2bbIae~bb zAnlWOr7LBeP=m7;y71Qy)2xV=17pcBtgxqH&|Z@XMZ|Sh1G_^}7(}W^o}Y=fuL!rp zgcWa3<++b$xXOcIEJm((gcB6!NNBlL6V^}h<|asElNJ?(JSjonF=`uD>tF)tQ)8G!;`{Wss9iE1#}y$StHp#h-U}{u@7u44eO5 znJ1wY?_YG1l36?r)!?XXwVttxw~JhPNB@q1Q?3g}{VuW}^NpFD$Sy#bY*v6vn9k zK#X4CaQJIk;eNTE&A+h=O=A6_(RO79P310lr=XG6`WssrU1sjv>Vr0pWT*!x9rveQ zZ5esdl%nz}>zQL6_}MzMp8jdIVQ}7}>2yi)mSCc0m91+z!RX}quUijB(XCn8d0%HR zv~t$dzNE@t5(i9}Kub2?SFRo4|G zRj$@;2a$|%b;T8kL%%ydEPEv7wtgbWR_Jjhe(nj>il8iYlwC8(uQs$cAoDAzh)lMA zTePLOgzgE-(_y8Gh{X0&w5)aDJzYhO^}|71@#?}tS3}N-C(;O)v%E{%d-lFJU%c&~ z;K{=YiO+Rhxrx4nvk=`cdi<3Zbd%dBm7A$^_<2qzqD%?iva~usZ5?#SO&*uf`cEHu z6<;e2PB=}x8(K#0(-gFm3*@N!zmTD?_!I#Yrg?TmAdO9AK5Isx%4dc?y9*uE;1IRg zANq6G@|hSKs%gC?uMp6J&s&(dML%H!h^H0tKiSq$;#0LiQ#PZWnpFl_ThD#ANvjLU zHC<76>y=^irR>hDfMgjDWdx|8IG+e}91Ug0`UsJE@>ZSh1gBg=I1*PgrdezVK4O4y z5b}U~-o&p!#QEcQ8zqMxPrPUj%DiG7Y4;y$khk{<85qUI{LZS+ zhrQe2GSpXMBYb#^M3y~VzbaAZR`Pf>B&>6ew!ybqtWl*SCu2fI2a(LZ{Yp^Y+1X}? zfRh4a+iFMnROi2sPdvDCzNj$}xSDPR|I^Q*&)}Odb#dcYo6p8%fx70ko$Ob`+N{O_Mzm{;hsG45~qJcLa-L1^4ntnW=|dsETclL#_x zom%TC)hNR^hvMwAjkk%)yJRzv0_CysKM^MLF6Ize7fE%qxuT!NWd_yC8aho|rxOh# zO4{o$Pqen1R?Su`akdE=G-R9}2;ldTdkr>>59i4TdB0ZYVobK0{{ zvHaiuv!Pn%fFOhKx9+3gsKM2gmn#M;n6NJ+o1#jP?J6ub3LLp^nfF>1Syvq$eC9Pw08SXRj8$ zDzJ%PN8}v)VDcb7q9m#@4Ef-XX`^cqKywMrEuqfpU`c_ddB#W^}Nin9+`ZZ@HPG@w{tSCYQwr`9LrA{2haOz4>N# zLsJ+AFSI#LnaTVde`yRTv%|Q;YtJs(Dr4L3%I7GF%HyMqe4Jy4$zdVH*cKASGpM!s zr8=JlHrVf(qwc27gia`AMNpp%^GWF%P@EgDvxko>EF})x;2#fK2@^x-j~74uvvoTg zaE=XY0jx1gu)J~i;|InT0V@49Fxj1jiOjO5(SlC44uI39#oxB&Ng8$R0YS;+s0E#8 zQMwsl_7H}ww5At$0Jt4#qb4eM3%rUnFa0c9>)~?J>vJ+ z<4KyuyfWObG2IwlS+B6E28m#x_kukykT!b!ZJEWE2}6&TPps{oK7h@%%3@b~O^VeQ zJC_tQY)*}^vZkSO360_?vguc+kbdH1kBI+QszIR@Mzoz+T~yvRlVAehSFD71CyJcE z5bcu*55U@cU1PKh92qenFQ5=hd>(4UtxB2uMV9p%3-{4e(~+3&4Gcr^<3(u4Fzz+x zVDVw?DRfoaPtwjtIID+3eW#|!OIQ2O`O(nxbS!0DlN;H@_4j7+ycPQ@PJ_RBOj#9l zHf;Z0HJ$N?3k|_VDs8m!%MVtAO-Ki?m-`*lB@IgppzXeVRL$KdMQ9cEfsEcmf*%yBlOs3D4o zsgG3RNr2xcVOfarH(CA^=;?6sY7HzG5~_`{)6F8)Vvx|mge{ArJO=4p^YbjQTz@rZ zr`bREY1rO{E#b2=`q@~cO#%UK`%0<^VH=0Edu`**Q2$J*+LE6$;Ln@1N4jY4`S+u0 zjB%tECO)`yr$Kpk90DgeO3?JDNi%sVj$Rm-lN~YRle~C@zi_$%7a4V<{Hz?DLgr^4 z(Kb``H^}aRJ@T<88kSd}GFv%8egW9LT&0umcIh8zN3?zUgnW3La;-ds zAtFm|XLGn$@n3H|lYTMh*ZVIaPGwo@qo(wtu9QR+?@&liX4**6^*K9iF70eC=&LRYzOcz@(3}`{b86*xXe2eJ+!bFV@=0>;8kYql~C4bLSr$)84!fsBnUN$LKw(Z z;Jo6DiNmzFCwks8usK!E44>;D2Y;I&x@=qvYP>>5qchkXa_OiQ@VS-C)ULwX%*=~V zpfaxXDHGgd%h^h=jsF|khoWhb&N+)Q+bgE=o%FSZQd!1miTc8G$@$JLqCn;Kms=&t z2A$g=22@v}gCAa#%KVpglQa6u79Z1))Vu)4q0{FD=` zC3F(YZvgORS#s^QIL*Co0`)H#M0eqSsy5eo#u8A)AhKv3JQ|7ZMzx*@$dj1YJO5fI zz2Kjm+C~y2BNbzd3>yqPxvdXpiZEIiX~F=0s{+t1GfjJDoD`^JL)$r+&JOa^mLCroN5ojd={?9H84Xbt& zXUC`u_fWHHH!)PJHdgM5$Iu>pnyrooX@+Mb=2q~sFVmdlz<v-pOJ@8L33Nv=sV><)^ZL^vc9~@4jHDT2eGpLSPttdSPhxs`TNd zQV~QM@<1aib-yn{??XRsOGN%yShCEDsF=r-`7=7-)vHM1SbF!@B`1PIjcZ@P?TQ}nCp6e6u_~zn18JH=1?B#xfKBm82AtcdjOIQm^)|aK3 zuo>`A_pWbNzLdrjTGI3VrbA~R=A}*2i%Uek5fJL*LO|iRUUl5S@U^0WF3^XmkW-U# z1!pGd_f2zwN}+BVBGF-Xl!!E_;~DF`9O?r*51G8q^FMuj&*LOr7(!*VloD;s_En33 zXv76x(FgWysOep0H+X%1FuvKhVa4S3;e)PvuhnW5VtzC&Zo2%}ppnx}@z2 z*5QFB`%fs^f{#MHzOcZ<*>R_TffpR5ot;kO_UesRpnm+7o=svn_xO~M`^JdXe!Zmq zF{}5cI;T@6ku0p(|Jm_4mP8lU$hUa!Cp-kpX2Z`K(o@}co7$X$ z3GwToCPl5wrll1ZpAA&ZzfnEm7oM=oCQXNWNC2E~8ZAcRclM(`$dw1Z$u54`?ivT$ zAV@-}`?d}y>~#xMu{)wn1p|KVV%$vA+bf{sE74H-jM;ZaiV;JTkScMj@MI^tI@x0h z8N>M4Y(t?0i?jVF#nN>bx^-B=aJY7~!Db_`gkAZ`iblWGDl+3Ev;R@(_(a;N8|o*I zL3_*I>twTRnE;uVqg^<8?%1Da-mNDpo*o%Lx?$D3l#xs-(tm9o?zWRy7mx`Hay1o6 z*WA}c1ch24>&pkorHPtZ3#g&!0Pk<^5urV>Vly!{mz|+@)}SI3!A&N^P>*mKSVwLrQgfS=@8`%C6Hr1##nT$B@u?4*}eCx>FC;!-v>fUy$!z* zG)a|l+TpVne8Ps%`3j4lACsttB@NpZ(aNmeckmn}(V#CCp)BKTYN03ILj>7e!}Qm( zNW8U^2=&RO040G+`7)NY8l2Z#cSu{kt7YMgsV+zHdq?2ERz|Jn8tmPX?H}QjS=V*v z%VbZFPZSFF4oZDxrjiLb6gGeh^1gCeC6ir`@P;`(NB!f9Dn&gv`m4_ejf`Zz>QpSF zVxuccjxDG#4>;Ggyl(Vc@UWn+BPn%uZ@~@P5AoH2rs)c1T}@zTARo)!$~^T+3znj* zt!*I@Wg$(tNYoD;ADNNCZHo7GI)tx@3K~eFgMUpCsw7SkWg;+AM7dkbJVMRbuS4`3 z>?y^LaNRaK@LTCc>H&g1J^x*2O9djo{R#OQk~ndhsUneRmjlP|Yr>0KDBdzI{5a>G zZ4k4MRX^*7A6%s*PtmeL-m}FPf$||bgHfTVwL#f&HbTba$A+O|Esp+O=fWH2DJWG; z^1u{u6)Etw5u>uwD`$?@#BV{kfSQ`Pm5}jjg{MLfj(J`!onjS`S<*C&-21DgU|Zub zwCE{zsy#$Y6V;7zI<@Mm(N$yM1-;7gU4MU5_g#=05MvH3w57{K?og&yrds6)H#TMf zRZS+}YUNC_a?rc!vpIdj^G`bLPch_VKHN`Dey|BkKK23k-u}HDF??;V%1!Zlv`kNF zx_vS6IUfY9r^Ty%0rVitV33MOT!u(4X)LU7VSruA=#r%|G0UNbPqiXne8xphl?DCwU?#8kwl%@N_i=2~X zoR(Y<-WATzR#KIvB$`y|XFeAKovnnt;KA;Scu`^10k!Aq*?q8M2xG=L02OZ5NYl!W z(J29*>l#}=kF+#7<830NdHm_TzmM1pKveMZUacrHC9Ub{fi8qKzvV($won~ zaxTXJ6{KkqGeTsnbdW6*H~PzJEl`i)X>?*+Vds$Y9N;wOvPK&#m`#6l+98o#_(|;e2h)=usQB>d;+j%EYQ;9T9n_nOt&+5qB zyrD_U$r=4qu%0P6U5BkmiVlL5u5#bf6^QzJrlwT7kn5ZVh#3ysGCkeO{#$ELYglX` z8fyN)Cv>^_89xYDUC4@~?fXT?C<^NjiejR0%v`yN*>i#0ZxZqdM~wQ@0;XH<$Bc;v zJY`?Oy)FWS-`MpgzhJ%1`&56l-GBBvM0;wC%9%jVzvf4J?@vB%2I8rk0r1>V$mdA4 z6ujFbAoG#pn>`3~onDIA_l|~WnX|wwWWrpxCs<4qa9zhQZPi5w?wbO^SOzVZF0L+uhI=17JkZMg+(3;T2Zh);;PSX&n{?uL+_5))1C01C?_e|osldP!1ftXrqoQ5 z*v{qg&raA2q9){_RO6YH5wIv#0@Mj~rmJy-uN(Truad*`Rtl{7vdOli9lsH{1Dm7cZlCijhSD z)W|}zcApBoxw!`pGfLK({aTI4@n^|tr`DU;jl3x|ZDx98;_)-b z>mBMjV{Y`2*O8M&B`MJlmN^*H7i#C2PuP3#e!^kXXO66EoZs^bp6uE9u55;B^T~|w z?pfj;V(`6rPsc96(GJm&YJ!+cOgpQXKy)+t$$yX~l*nS(ggk!a?yyFp_a}V46-}+; zGR88>OX$zb8kB=WKS*UA^Phr0q`i6)96T~A#&pSv3>$DRwPnS2tr2bPP?R64WDgX> z-P$K<%gYc|a#%i2@#ae|x#I-7VWzv9Dlz(aer^bHHWddE5TI4;z36@e;GY!qP?S&$V8!wP~CE`wN~ar66U|m1XvYhyyKfl!iltR zbYRMO`W!tug5Q4pWcfH=JpLtOe8`MKJ&_b#JeK(iVJibQ7sl+;r^DVhlEM+_v^KlJ z9w2)C2^e%~C z*0FtB#A8ZjaMbwVtCc*km`bwwx>uFZdb7TF^^-y@2UZ_y?;Yd_*ihnkUmtQH7 z{DT21@i6x7uQsnqE^B~dm9WU;cVI-beiIhB{YWgiT)0hP?YMZ&E_|O>$Pg z3`cTglRC6lAPN4Rr64|ebt4E> ze;`Z>4Pp8WHV;Hu(&rF(&j9Gd}8IL`hrmYo+{c!PG!T$pDLRUk~`j;W+4{qp0c$ zNazdGO(hmc2m4Dr#L0fZ$ltH-6N&mJnZPOj2hgDVT9DdalH1$<@G{;jEqw<;d;juKpS|rimIJgCy?Z~m9*|)EPhdRLVFlRh&@yck!>>YCP*H6s7?yL2-* z5i2WU4|zYS>hfxs3vHt_SZj94iwfD%|4&BA@G4uU4;B+LzbSgRg%9KVNZXLHBV)Lq zqG$MX%|{Yaex_uL=z<9`Ru zQxATnL`&I^LJ$J*DbtQ2rnAt>LM%-F zK|rtwgcU)}7x~z1Hrcs5bH*ZO$!>xO8K#?==bZPQ_ecnV>#P`H`QzGaRhd62G_&rC zTLU$c7_x*nFP_dW#Q+*);mMHE?j)HexK784D4x9l_tfpz2$@1y}9rkF+ zI+J5NMWeZyObc!d+rUc=>D+uOdAOg#%+Ej6h+wn5^xPmVVH*Eu446Y0A_@ zo$rlds-+sL10DbHs{AQG2a)rMyf zFQK~pm1x3+7!nu%-M`k}``c>^00{o_1pjWJUTfl8mg=3qGEl8H@}^@w`VUx0_$uy4 z2FhRqKX}xI*?Tv1DJd8z#F#0c%*~rM30HE1@2o5m~}ZyoWhqv>ql{V z1ZGE0lgcoK^lx+eqc*rAX1Ky;Xx3U%u#zG!m-;eD1Qsn@kf3|F9qz~|95=&g3(7!X zB}JAT>RU;a%vaNOGnJ%e1=K6eAh43c(QN8RQ6~GP%O}Jju$~Ld*%`mO1p?P)vCi#|P&Xm-dkucwL z3)87{8iWe96huvPHfK`KOdC2Z({T6vJ9pwDx$D4>d(Pqff6w7Lmj{5i6;ZyPPpPN; zroaW=6d#@oL2Fa53F~$Su10(RG%K0p3VTuP3?Z=nBA8z$uq+XLUL^QrC74`bU|!e| zr>hK{)%Q!vdmIO5Z3JIvaOyjOX`X@c8-ua03`Q&)f&%p*{(A$q`ZTTjk%q_T7>v^J zu!R-a9fFLScYlKkNBP_Cob=9m9JLVoC-?c{)eOtMnh7qNN{ejy2sM{pS^mgFHJm@(buuM4>=<5Vr$&Kzw{B?uPr; z(1Yf=#g)zADkWnx=MR%ykl| z3Ui42k+O2{bCn)01-s5Sxp|z{G2di&KT(_M6;$EI zDL57JFf}cw4bP1P$pgTRKH$0@h|~aA>j`qZ2*kU5t2EVD5#~@VNhqx{vz8ethDD-=+1vnemftUBA zF;N!Q%PBB5B=KLB#QO(CHe?;R+-C8M?ppDW>R$5`cCPq@YpusFRTaH1i9Kv;l<>I( Ze*oTy+;kdDB`N>_002ovPDHLkV1l3CM+g7_ literal 0 HcmV?d00001 diff --git a/css/markitup/stroke.png b/css/markitup/stroke.png new file mode 100755 index 0000000000000000000000000000000000000000..612058a78eba4e3ca259aa13417fd60cd6cf2fbd GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^zbpD<_bdI{u9mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-%c@9E+gqH#X?$N2_%qkv$?&V++! z-fX$@sb%KG-5a$|R8J4(Tcmu7`G0cYgxS-++3#dN^zA^J(=P8r|6lx9G_LLaZ+v5S z!d9iC@)!RP{FnQmdw#}3Ee(C$|NPA#|L6YmR@fBO9w|C7oMMQ$vc`Tu(RETvC<`(>OPJ!ieM-fH~m>7>jD5&Wq={HDKYyh!yx+)ugh??;-Fdk_+?B=(Ett`c{f#J|ni6^h+9saOK z3Me!bX#Qh3B9wk^&d+Wg4grT7BK8ehe_Hhz1sr%56u2D&0-YC020Q`|Jxg4;92g7@ eConKD@i1hIny&nuDpC)04TGnvpUXO@geCwEnM@7< literal 0 HcmV?d00001 diff --git a/css/markitup/url.png b/css/markitup/url.png new file mode 100755 index 0000000000000000000000000000000000000000..b8edc1265db4bf69814875d1c10b8761f7009e23 GIT binary patch literal 957 zcmV;u148_XP)(^b;|pVbz=yzjpaJj$ zhrpuriKefui_0DvN;1Ymq&%nwWg*IrK!Xz^eJWuq3u2H~0ra?EC@ge%+`A>6mV z9{TYo{=G6 zt@5m|4G+Q2zKv;Ch@O;`PfWArmB5n3gvMsxV&Iu>97{a!2kL74wd@!f_AP^O%_&ND zm}1c*+F;TcH^{p$P_|akvD5o7vmT>HCkP;z;;&+8tDBI;koi9eX`W!oH4`pYaHlFZwV;$>vvfQTw zM-`m&R_SPIBa^FUasC0GCCh%{h`$~db`z&-lFX#%(f>H6JD6Z(sIW`RKE+xOL+?+uQ%q z){?+F%=6pqEH{6=NzusC-*<`PZYiLCGyKD}Z8^V8ul-K=AV@SE1t4~D2*b1(9UUc= zN-;Dv#Ngl{rd7e$ZUPXC##BFmV>$26ZQi?6Po#@{4gllsPbku3Vq${Y+FAf~T}OJb zGWEz9{(zcvI&CUaN&p7GcqMG4&7ULx##68M4k(F4l7Q+Xm&>uSv4N&(w6?a=)YOC{ zoYLN-J?7@-9xGBx007$C+kK7w_2Z$(k&l}jo2#`dO;J#Ipsbc$pS#^Dy3Q&nSeE5x fGMT)t>sS8=`naU3reLNz00000NkvXXu0mjf)bGN+ literal 0 HcmV?d00001 diff --git a/css/markitup/wiki.css b/css/markitup/wiki.css new file mode 100644 index 00000000..40b01421 --- /dev/null +++ b/css/markitup/wiki.css @@ -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/css/mtrack.css b/css/mtrack.css new file mode 100644 index 00000000..179512e0 --- /dev/null +++ b/css/mtrack.css @@ -0,0 +1,1393 @@ + +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: 8pt; + +} + + +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; + white-space: nowrap; +} + +/* 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; +} + + +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 { + + + border: 1px solid #CCCCCC; + color: #999999; + font-family: monospace; + font-size: 0.8em; + margin-bottom: 0; + text-decoration: underline; + white-space: normal; + +} + +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; + white-space: nowrap; +} + +#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; + font-size: 8pt; +} + +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 { + clear: both; + margin: 0; + padding: 0; + border-bottom: solid 1px #bbb; + +} +div.changeset-committed div.changelog { + background-color:#fdd; +} +div.changeset-good-to-go div.changelog { + background-color:#585; +} +div.changeset-pending-review div.changelog { + background-color:#855; +} + +div.changeset-live div.changelog { + background-color:#336; +} + + + +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; + float: left; + +} +#content div.changelog p { + margin-top: 0; + width:auto; +} +div.changeinfo { + /* border-bottom: solid 1px #bbb; */ + margin: 0; + padding: 0.5em 0px 0px 0.5em; + float: left; + width: 250px; +} + +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; + clear:both; +} + + +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 { + display: block; + 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 { + background-color: #CC3; + width: 100%; + padding: 0.5em; +} + +div.button-float-floating { + background-color: #CC3; + border: solid 1px #ccc; + left: -1em; + padding-left: 2.3em; +} +.clear { + float: left; + clear:both; + display: block; +} +/* vim:ts=2:sw=2:et: + */ diff --git a/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png b/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png new file mode 100755 index 0000000000000000000000000000000000000000..5b5dab2ab7b1c50dea9cfe73dc5a269a92d2d4b4 GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F!3HG1q!d*FscKIb$B>N1x91EQ4=4yQ7#`R^ z$vje}bP0l+XkK DSH>_4 literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png b/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png new file mode 100755 index 0000000000000000000000000000000000000000..ac8b229af950c29356abf64a6c4aa894575445f0 GIT binary patch literal 178 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F!3HG1q!d*FsY*{5$B>N1x91EQ4=4yQYz+E8 zPo9&<{J;c_6SHRil>2s{Zw^OT)6@jj2u|u!(plXsM>LJD`vD!n;OXk;vd$@?2>^GI BH@yG= literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png b/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png new file mode 100755 index 0000000000000000000000000000000000000000..ad3d6346e00f246102f72f2e026ed0491988b394 GIT binary patch literal 120 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnour0hLi978O6-<~(*I$*%ybaDOn z{W;e!B}_MSUQoPXhYd^Y6RUoS1yepnPx`2Kz)7OXQG!!=-jY=F+d2OOy?#DnJ32>z UEim$g7SJdLPgg&ebxsLQ09~*s;{X5v literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png b/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png new file mode 100755 index 0000000000000000000000000000000000000000..42ccba269b6e91bef12ad0fa18be651b5ef0ee68 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnouqzpV=978O6-=0?FV^9z|eBtf= z|7WztIJ;WT>{+tN>ySr~=F{k$>;_x^_y?afmf9pRKH0)6?eSP?3s5hEr>mdKI;Vst E0O;M1& literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png b/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png new file mode 100755 index 0000000000000000000000000000000000000000..5a46b47cb16631068aee9e0bd61269fc4e95e5cd GIT binary patch literal 111 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnouq|7{B978O6lPf+wIa#m9#>Unb zm^4K~wN3Zq+uP{vDV26o)#~38k_!`W=^oo1w6ixmPC4R1b Tyd6G3lNdZ*{an^LB{Ts5`idse literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png b/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png new file mode 100755 index 0000000000000000000000000000000000000000..7c9fa6c6edcfcdd3e5b77e6f547b719e6fc66e30 GIT binary patch literal 101 zcmeAS@N?(olHy`uVBq!ia0vp^j6j^i!3HGVb)pi0l#Zv1V~E7mI3`<(O3xvulR&VAkQJHZBho(m=l0{{SA7UpJl008iB z3Rqvn`1P1SiomLXkg776;)RSXXXV1Iqu_@e2%8dEPZ*NvG6-d*$oWlBXKKg zV({l@ll0gM+F;pm#SBg*2mQ!Rn_HBhT&5w_d`jyG6+_vuxMHXoKj|Yh2EGJ-B`N+E z$pmy>sA-*C0S`BfHv`&Y>Z626r?uZY8?`zzbXj7u1}` z;TS<~e1eY(jD4j)wElgyeR*V7`qdhf3S5Vcdq_R*a&F^r|9|M*i>!yeL)xMH?-6M_ zJjl&7(M|RQJ2z;fI7;E!$?Pfq$usWpjLxzlazT~K6v`ft@@P32;&o$5@b}Yj#d~r) z9^2%vhdyIgOXOGiCNOR_sjx3j8*01pUqQBn7r}I@E53HUy&DusRETO9wG~Rdfx=Ta zwD>0smtXx6l#X>f`lTc3c!pmLbwTP$Zfe7s__87<&i+s33P`Udim99RAA$T_Y7T3^ z>vV9wL8Sc0x! z_eRl4cEFZ`EXPfL3omdIIY|MS@P4-79I_Af%(!ONP=msk&*mFs^(0gOj->4HEJ}Ca zL(HZSEXEQH#fbJDfQ^RQnvtlx$kD>NeLhPB+yUp!E5O$&?fP1}JdI;l4(=H(hEfAQ zNRU;>uU@{f`2)^*UI^NA8VHraDlXrE*?OWOs z7D#P(ftiy|@ab?=t923@#mR}=S6GNj1 z?mTR4hby}vE*2>Wg7-X!KAz3vwvJ)qVMtB~**$wrQ^&0>;8UR6E7imZV-)iH?Tt~> zX-EGVhMYWVxX}dU)MQaN+jv0*8;3JBy*az#1aW|^_4%i?mlU$yRTy>-wCJJVC==P> zEx=B7cZ&E7jJ@{Z{CG+0A-lAG;ovs3FALs8|JLq?o#M-to~~wx^JI)GhP%l=X?-mS zEbfx}Nj)D74<>(1{)gt2^%v7UAlLYp6gO$gsv=`$#2)3F9ed8@mcK6i!h@mGQqU}e zyItCAfl~4IqG~(AU2lV?`)nu#S5+1BrCJv>QmoI?LyuLj8e^o>li?U6OMey{r_T(* zY8RG<@x>cK$(nNMlhy)E`{;|c6$@%L*hZEYs{mUmt$8-u8m?YV3{83m{YAwB%6Y{L z6k9V^jd0tnd%q4+xwp&Yfr#>WqoooH9K5xYM|V_s8{16~N?TcuYd@6+y1_aS;c{q^(Kyv6DZcFd zd@RkCqyC{5yX5E=oHd-`WBQ0I>9_&^<}<7793`JA=$mRuSrr}iQyzxG9T)%=Xp2g4 zkFI*p1^XIjQQE0yQNGyZNn{h@1;N1>r@)!(21u5LGg2Ob1==Thh`ZXost~Y05y+XE zrc7k%zx|Fxe^LX9HhqjcV~P|W`3AXYj%WAaFNz@uZ-xRmf!NHrNh4zKSO1WrwFL6P zXM}G=*p9v_k=mUmpg-$Y6I7Mt4@y2D+ys?c;_C@aVePnKabqAS%y%AoFzKI#JaeQxo%Il=}>GqqqxhG8cPyu>P?R=}Ol7vhvDcW{Z8i0Zn zzm^YCS5qT4m#*SycTaxzIpnMMHwFrEO>lJzqr0i6lGn6M7x;$7B7Iy)6renY$OiZc zMEFF-;Ff)@RWrYEodz{P?avD?^RtUsN$GEP>xrgxlbtd22`L1q+Vm;zyBzLIj#2fp zQZS2sUF)*%MR5S(jid&TIT<2`Js!yUdi}%lzzxkuKjf|bHvGZz#1l5%O0plla6C28K&%)=R}0F6xRI>HvM|=4x#=-to|lSN^N9P6&xIP z2dq0{CX-Xc&YJNeXXD#dn;c9feR-*P_CfUEp8(wN{z!yEZrI*MPs**fh@b|xe*S&i zHc8i5C2XFuJ)xhg7K~%2H`zsX?JhZT+>};UB5HaE$E92V@>aXAPbP zjHGY7LH_&c+;-7yblDf5tKrky!+N>Vx>?)QZi1hm1Aea(92RyRiFczw&w7)GT*KddVhT(T~0Egdo9qyLRosyG6?!=QbqPzk^x9!b!;O zjEYZ(YM2+oYg-TrJTt9??(26|bMF?&#cgl&%SzC;-tOToW%SoAmvaoExO%bz%?xjk zc(|{^J<~z4;>Loltn&Q#cD-zLlA0oFa(P1*5{sdl$v0#75<`$?CT{uv?urEF5%l#% z1*lLBO|PYH2z}OUCDP!56T6(s<{oG|TOAmiP3Z95>EKzFu=~wRiHd}%-yn`p^?J6( zih27|xpMpU0(-^Ma=J7`xm^&DhSqXkjnQt=LQjM?m_ss!!0cIcfgCXk7TijCGz5At zUKx0OZ(Pc2owm3zR5RS0N)Y#iMfl$WQCVB&sa%OY<#3FtYF&H{`S5{&n#aQKe2Se9 zB?KD>qbcT%&$2w0lfgg>hoa-{bj}D!0GrB0(o9%dP6Pxsw8y%(rU7O|*#fSHYBm2h zyytq$C(2?`j}W=ORiP$Y;41*}G=Y$(2OhqHVfd_b2NmhSboLunMtOr5!~U=jF_g7g zx!U^R$M++HtM%nJWA0HW6A->{j|_B;D@i9waP$)>{6HyW zi?%Q-uGS3xs5_COdmgZjld7Pfo4dBxil@eQDw4^F*Vcb}d)bfW?|OD#N(nd^;T^jB zZea;L9}obXL9cH4o}9qQv(@ovFw_meU5D94g#m>tZ>F(pY-+sVc~p1lWWYncfsZBD zlLUulh#8ZKbJZaXx~7T%9*9kCI?ptUWNtB6zk6wB?Esa@U>adq3-GJsAap@@buxd8 zEh*0kH65g*0pwfcCE82`98Gls@jB5(U`@lWMLxq4sPDlmq!Rv*Vp(zSX$437XGBPqZRXNva3-1V4LK`FF19js@6mZK*48gf-Z-ZNB zLM=}?fKd18YCyN<3I%#wqeFjR9^PLn0C|nbyn1-&Ph!re@O0EEp`97_ouN^T>luaA zQbRd68s2B-M1Q}bL`59M`{jC(<_`P4m+_LOgr`2Gt(Rm4y+wDaGcvik0$;t-0c3C{ zKhx0TB~7CpakFn?r9>!&+;ccIO!hd{$-sX1k+O&#=VmV@?^gOz?c=kZ*8x}L)H)dP zYzhfqNU`(IVUtd)A!)GN@5UL@&OX&+@1C?lb`+!>)>=w1JnE$X>Lw#Yjk7&t)#5>X#Cjs|&jQ!X46aWn?QOjkKm*1G ztbhAifM)AKF=tIbp&vSIPqX&9FQ`BEN|??$UXR)85VQkj*P`!)ht-9)fQ|t&EI}c) zY_Dp0Km2C(q8potDF7er6kZ;VOs*dAVznYFU=Tj)$Gq2%pheYQJdTMt)xV?d0aA0f zf!9BB;E?X!!FWTWHx>8q_1{a`32+aVn2QqF4@>>wO;ea#m&96EhNkjIR(#vwq%yr` zfH0w))fHpM%M^W;nW$_)tb@EVVvhrYi*g_wUlF^|U`HFf<~&JOeBOMX&56=R~^VwL+|j!Ca?>Tx==&$#g^C#2+mS?tyG29g?7BC;5|* zhNhNJ?*-LgdlM)3Jx?L+w7;FK4mFXC;;XzQ429NM`AD>QNUJVX`T3s9}m~hbK7csE0P(!l|C~FWjU=g#?C}12ipKQAA~kz3%msO zg2N0*dRqd|SG=WcPVM-2UAcd>w1y8d%zsl=9Z^nq83TK_9xPH=!{}}AuqY7aaFPnP l;BjQ_^4`vQQuBMqxOYB4T*@HG=I>V@U~v|0R%wcf{y%IJ0Z9M= literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-icons_2e83ff_256x240.png b/css/smoothness/images/ui-icons_2e83ff_256x240.png new file mode 100755 index 0000000000000000000000000000000000000000..45e8928e5284adacea3f9ec07b9b50667d2ac65f GIT binary patch literal 4369 zcmd^?`8O2)_s3^phOrG}UnfiUEn8(9QW1?MNkxXVDEpFin2{xWrLx5kBC;k~GmFhwsn)TR1w<4t)tA3_robX4CdCOHJC|7j+vW z%J-EMX&`87enIluaSc0_SnYUx$GzUc?vrNXt&I`o?~7C3RJ>C-Ajq!3AfU8Dx90^_ zp3}MKjJzYC+`T(&egFXQ#9Ek{*oVAaa!zrZtmlRFnwQPRJXH<%pkK2*eP`pT=lwD7 zifq+4BY_rUTa+U|2#&?i7>PVvD?7R4ZfOLPT{e9G~G!Ls3s8JtQE`jMM9wl2V9&Q+K2DHW0M+uQmEr%nYJ^7cK?uIpU-)=wn71ZZ-=@ar0;3^AY z5+TI{2b(e%t{2PZ^HKF*vu@+Xr&BAc@2BC4 z_vCgww#i=)ea5Vo$glEEVBBg_VPBj!)OO>)f@}#dg6ULOeC>LBHz<;*5Y;YfE0lNx zg{N+4@lO~ozxpF69qV@VOGnc248Iuag4C1T)P^(hWkpP!{h!JekX}m^Q#b2B4f1oT zIjsGz)4}-$rQ*-tSuc%qG>%<4xM#E& zN)7lRK~^2VdiloY4>;#}A!yHOAXEmEi^+eA#05pawGXs>!z)gSoDuI#>bRCq-qjJe zZ)r=A`*EMX6+)~er1kdv1L^)0-PsAEM7JF$O6G8>496$24lkOSR^RTfUuIz%iSfn5b-t!##cs7sQI);gdAvqmn_v|%I9k;fCPl0Z)R1+hNQONJN zH%3jT9sOq*a`LF*MiY=zlSSQZ;{_FL9M07A=In+O!~wR}=bzGEQpk2!Vc0p)qKAH? zOk{(%06W#)DdICQ_S%Q@<0Y+!?9%#$gWJ%)EO->^YZP{<`oB4~9xh zL9-0*c4@B#O2ylYs_g`Ky$zb~v!M`NRaMNFYF*Gsu|7)=JyyMHjFC=HhGUE@{aI|B zJ~ITXU052%7jFb5Ys#fhS_?4kqc7H0EU49B8(Chg0&JzU=Gka#xOz1)H0d4m7ZnRA z=M^tdY|U6T!fmte{W?_r8H~qdq|q{5AMU_2It1I4143n~xL?4&K#BOB48l9_Rdm!(c^C?JU;tF0 zEh@o1y6Qa_>}#AwX{VY+`C^kNkxhgb1P5cB0%xupAXyg9NO=SnXrJUE?rQg{Lcsn+ zAZKctGLfbK_B#^&Nev|0^fB&?DN=ak8|0!np524LD25=s84BP8Vl(3=jflNp{X>e@ z637Ri5xx;&JNl+XYImA|{;XR~P*svYDEWYJ6I5!6uO~2twFC1ZQevB7#3z~(apxn& z^J@>Mc`>PJair{yT`iuan-V+i%|Ho-pA<1?V-k^R2Q<5;Co%XxmL` z018t4T0TTwO^w)Gx{9OSJ^9_|kgwX`7%0Rw!PO~@?xvnfUehvN;2Rc;^l>3kfbtk3 z8{j7p;S&{uTlTe9&HTc38q@%_KQFk<&n{vmrN7y&Cz{etcE->rq!6HL)2F!aa=0%! zM%Bwo!7TQ5t;@a_#Q}sjk{UebWQZ8{cp&HN^$*JfH#8spkhk{R@CVBiPuP@yEhu{} zsQfuhTqV%rioATpEphMfhyRYbVfVW`YwLFXUWm-===J(byMf!5;W^CV1g~2194Xx) zFK|z{pm%n-)-DRe{Qhk(d!QaoI*y%Wn6h7<6A{i*Sob&B^y|Spg!&J$`kN>zwUJ3x zaB$ciu*0FJKg}T ztgnh)ASF8njz5>h6?f#{c=*Yr4W_34$GmVIo8OLWjcZK4a0`+Yv-!*}9 zBwKm;DAsA(nDI-`iH@;`=gP+m{lgFLHK3m$W@?)&dGhDA_Z2xOzI0$p(ZJtH$vCxE zj>+kYNBJzs-TlSx!tSH}%I9fQv)mc!C7X0bKlZv4f&}C3+O-4k7AmVO|KYZ9ydP%(N1^uisV8y;~p`x4qFXD?!_OyN9=w(Od6W; zGrT?G;l2v@Ob5k^8w<9w%Jbjb^|H}PYKo}I~bobd!XrTbzp2Zp~H8lgJ)I3?l&(bDiWf8gE&6b z>)9GB=Iu-6%I((+>=jGP>CzD8c0oWITFZGgM!Q7|JrUYq4#^Y(vuDu-a>OWDa4Y4} z5a_*lW#IL_aVf8L+Ty}c&2VojLEIA-;eQK6Wo?xAuK>i;1VWx3c=!s2;j_*iRHOsb*>6-CgcYP+Ho=L@XLd*j~2ln-;WHg)|cCixksH$K={5rGSD@yB%LI|(NCc8 z1Er8H+QO)~S~K{g?nH|2dB8SKs)BxQ?%G}}o*LV!NG2m*TmR|pWj~g`>)ClJCE#F$ zcj)fBg(dKOKmc$Cy}IRlasngIR>z~kP&WW~9cC951{AKmnZ~ZMsqup6QQf7J0T1;C zK9*Qd5*(HxW=tl|RfjO>nkoW#AU3t>JkuzWxy4-l?xmTv15_r1X@p@dz^{&j&;{Mq z$^0$0q&y?kbdZh)kZ+NfXfqLTG}Q^j>qHlUH4VEK`3y^-z6Y<6O88Hf4v^;}!{t-a zDWg;znYu%6zA1~A5~w?fxO~i8-Ib(^02{c4pXjhDI^2 zXB1LP4dvWuc%PXQ{r!d#6>${rm+M8EJM8yf#!H$Kp8AxwUXm5`7Tu-J$mHeCG>vw|&Ay415}_1w&*9K8+2d3v1N+@a$|820o4u60Tj@u&kI!~q2V9X; z>tMvQDI|O$#m+m2O**ZHq`_{#8)ry6`&5s~2k{O4Du16Fn0P;&_(0!e5%Bel){nU0 zJX~<8U6hoI%yx}qGY_1Tq7YKDJ)ETOCs&W)TiCrK*1%DE*vXdD-7hwE*LUgjeHRM` z&@pkhTi>m#Kc+QIK+2Ybn9-sFVKNHyIgfob4H_77yYh))Rq$7Pw|+aD6&yZ|ki9 z8Zb6s{oBt1G+PgfIcxd}{m@~1nzhe;LH)5;!gS8@ddyabpdBc?7JVl?tS+<#bPSMT z2@0uYdsWN(;Ww)n-PlA-0r+62@bYkEa`k{0s})fJgYZ#5=DmIdEvok7aZJRi{w-|} zkea&6X}ZA3b7&vbDb7)v8CuI(+zzSf3z&P2eOrPNP?D~ zf zn0@)0h;~5F&BG5vOFU!=woW&ZSl~nrs{?1w>nWfW_dnpTd z4qvLDYJ*ft>Sp%M(^_xCZpNBnc66JX}A|ZL9IENM`U>`ph7d<+RQiI}@E8Y)70s zMC*_&))}GlmR}@{v9*nm)29-=rn`Q$rc^4G)GVQHlTr6BpGxtHuU(8AF7Ffh54?5w zj+EYT9>x)PWL-iQ@RNmT?R+|c@=FOmj)5Za6_ z@DkVy4l^L>Z3#SI@s_eVwd3D)<^Ivq8a~J{|4mhOL^<7M4D8){ut;GIqqn`oqCk|x pNh;Wa$C0(mdpqYz&F>xK-uVD=DT5%Jzh8ZT#aXmjr70%*{{S|9XD$E$ literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-icons_454545_256x240.png b/css/smoothness/images/ui-icons_454545_256x240.png new file mode 100755 index 0000000000000000000000000000000000000000..7ec70d11bfb2f77374dfd00ef61ba0c3647b5a0c GIT binary patch literal 4369 zcmd^?`8yPD_s3^phOrG}UnfiUEn8(9QW1?MNkxXVDEpFin2{xWrLx5kBC;k~GmI3`<(O3xvulR&VAkQJHZBho(m=l0{{SA7UpJl008iB z3RqC-Ajq!3AfU8Dx90^_p3}MK zjJzYC+`T(&egFXQ#9Ek{*oVAaa!zrZtmlRFnwQPRJXH<%pkK2*eP`pT=lwD7ifq+4 zBY_rUTa+U|2#&?i7>PVvD?7R4ZfOLPT{e9G~G!Ls3s8JtQE`jMM9wl2V9&Q+K2DHW0M+uQmEr%nYJ^7cK?uIpU-)=wn71ZZ-=@ar0;3^AY5+TI{ z2b(e%t{2PZ^HKF*vu@+Xr&BAc@2BC4_vCgw zw#i=)ea5Vo$glEEVBBg_VPBj!)OO>)f@}#dg6ULOeC>LBHz<;*5Y;YfE0lNxg{N+4 z@lO~ozxpF69qV@VOGnc248Iuag4C1T)P^(hWkpP!{h!JekX}m^Q#b2B0{OYr9M*o< z>EL{WQt@Z+Ea-hxX0}nTSZxnpi^#Kn8Ox8FgIS|hc}KJQ4tm*HO16ui{(O9}1YN)G zjiQt6fGq`Cj+^`zUf?8hk^(T{{cOQGWFP98am}is28A!5%{R#ENv8fCN!j69lMEK(2z?|BY=Je$XD9mB-Kkem*(d-j^9j$2#6r$Dz?s)-TCDCGCs8>6Pv zj{Y+YIeFA@qY22V$)awy@q!9A4rgk5b9TcC;s9Ig^G|6nDP+5=Fzg&?(L=vcCbGd> zfSu~@6!94td+o#d@sid!EIX$rx7*cawe6`dScJ z+$HssdOjE)O#Ybs56vm-FQ$7yuJJD^Zqk%hMaIgAJ<2yb_MFQte_i;62ScT$pjifY zyR_E=rQ+>H)pmlr-Udzg*-!|ssw(D7wJvC+Sf8bb9;;q8#z?0p!!bsd{wy|5pBaMH zE-Ve>i#LLjHRaMLtp%9&(HCng7Sw96jVv!#0k%?F^K7&=T)mnYn)D9(i;4x5^NJTJ zwq~pv;kH@#ejTd*48~(J(r6j34|m`h9fEDj0im)~+%I5XphWymhT;_Zty|Q&zjPg# z-ufAHZ1M*Gccw?Kf|8Pnhtb0`!{N`Bqsa37J+>wC$!e00k+2 zEgzz;rbcWoUB%Jvp8W1}$XD%e3>4y;;OZ1ccT-O#uW6Ys@C}Pa`nZrNKzR(24e%3) z@QI4SE&E!lW`5y14QhbepBG%_XBV-O(%5tj)@9#|;sC-MNev!zGDHk}JdpGC`iJF#8=8-P$Xoku_=Dw%Cv3{U7L>gfRQ?<$ zt`cZ*MP5GQmbmx#!++P@u>0MewRO9GFGS{b^m_fJ-N0?j@EqoFf>$khj+E|@7r3We z&^tR^YZrxKe*d22agXqCO0l44&kqCv{u)T|(lv`~PK@DvE{QI_T zlCH5z*gR!>LO)k67{^R+vWx24U2^2ODXpwT;6y+6+$5m)_*w4WY&#do9dCeE)>p+Y zkdhq($DhmMiaYXey!_kiL26uz($aJ!QT{B^Wu}U$^9e#5)=c+XF9@Ill?ZmMlNgHi zz*9!vDc&uxOo;ZVxb`Q!Sk0*gnfxWzmbZh4(=%CD%qP?0=);n$&zaW_$UKV98axdc zN#AyZ{P)wj?V{P}vM)YY!>6@}^>U+iv$`9>nMTCPjN>z%yF&3yf%>+T@0vh4lC8Xa z6zeo?%=o3}M8{aebLHcO{^1Ar8qiM=Gquf?Jo)q5`-+?sUpg?QXyEUpWSm+n$K-Uy zqkIwHLquru~o(OF)hhz$Y*|X>ZIbswnxRvr~2=rdO zGVuD|xRlpAZE<0!X1F(%Anpl^@V^D3vbM}qxe|NI;TTiZy7(IM;R69RkA>a&6gwYE z2sREzQ_LHmWqB+ogMk(fMaSFeoDq-!HkFB_nXt5+2ncFuk9BQL1I&oB1zZi)YW{6_ z&-Ip1l*OVRA##1ILQS;5R{-K^0wGTiJbVSi@LA^$D$;@J>^G{6@&+%4{b3(sC~LEH ziTv(0b#zxt?YJ0r_~pUZM~mQ(??(n#>&tD%+@nq=Abj5*8R!~Ul1`G~=qFJ4fl|m8 zZDCYgtr`4LcOpgiJYX9qRY5;DcWti~PmS$VB$E-Zt^f4)vLDOe_3XTq5^ylWJ9PKm z!V-8sAOJXnUfuFNIf0R9tK-pNs2hO04zr620}5B(Ok>yB)Of-3sP59qfQNbmA4{w! z2@cB;GbR(~szVrbO%(w=5S!X`o@o@x++wbN_tMPT0Vc)*I;Fgsbf^*g02Di?H zTApwKq3+YwfNsqd3iP%{hyK1iyuVZc@*0tO_3+N0#GFsz>8MjeJ2UJ%L!%hiGYYAt zhH`E+ywA*u{(eJ=ia3h*%k?779rk-K<0VZAPkl;TFUbmei|$fqWO8!_zIvqt$ly$V zrlH46nnpX~X5Yk0iBJl;=WuA4>~X4-f&K0yWf42h&0b30t@NYX$7egQ1Fp!abui-D z6cWCWV&|R1CY@G8(qOmWjWeX3eX7UggZPGimA}soOuQdXe4uZ#2>5zN>qlI09xk}l zE=tNpX1m6*nFr2EQ3xs79!^sCldDJYE$m(qYv3q7>}1R7?iZW7>$~*%zKaC|=$N?M zE$>#+%T&MZC`dW1wUl6Z)JgxkeN920S>e@EK`q~>k| zuYcsgA>F%!@rFciD(>Iwzn8KT;2tb77bUPCmioh+rZBfIiM6f_P34cQ__o1GWqQp3 zVL~~pE5?qODf%iiQQ3f42YF@09tQ*$4v_EKUx;t1KCPCBtgqg@+Tn; zO)a0uky_%jm+WjNB?=~VyH>V#L!*=l*@OSMSVyt_UEH&NA=?V2stHPyKkVN!&jg<#cjros){#ji)dK%)We0 zL_478=HZ8-@xnwsKrWs8)x`MB;(Y`Cmu2c-&SH(vN-F(*e`l?c%+l$|y_AJJhcDGn zwLvN+bu;_sX|1AiePhx@u&%P$hf*xE+O=~D?_(_KGWQ!158YL-y9$*6mmPo;Rp*Dl5lm-mVM2i`h-M@nxv z590_tvMwPD_{l=b$iOm|+|S{D9&P%zeT$GgX6Akl-tfUF>tL@Ld!B&{pN39tH>3V> zqksMAYul+jb7UiouWVGPNsxX7Ueba+9|~dz?d*QM$ng0DZfO0`7fAy?2yMm|cnRzU zhZ&IcwgjH9cuU!w+VStYa{p*)4IgBf|E8)sqMYtB2KH_}SfsFq(c9i(Q6S3UBo%DI k*Kv;w;*%(i9W@fAqs5i2wiq literal 0 HcmV?d00001 diff --git a/css/smoothness/images/ui-icons_888888_256x240.png b/css/smoothness/images/ui-icons_888888_256x240.png new file mode 100755 index 0000000000000000000000000000000000000000..5ba708c39172a69e069136bd1309c4322c61f571 GIT binary patch literal 4369 zcmd^?`8yPD_s3^phOrG}UnfiUEn8(9QW1?MNkxXVDEpFin2{xWrLx5kBC;k~GmI3`<(O3xvulR&VAkQJHZBho(m=l0{{SA7UpJl008iB z3RqU$@Wfh}nb?QCTyjovo2=)B^qQB=#XMCF_n=?1Jbh>5sptJM?}}{I zHzR=-V_TFXKM0P+&lrh3TPr)c<8EmLl3g~EY}W@od*0X6Ljv>L(67bjz58EDypsu&ddu2a@@x)`5aA^S^DxkW8rs_vKtu8N8(o0 z#Nf}*Ch4&iw866BiW!_r4*HRsHn%80xlBW<`IOcXDu%LQam7$Ge$q#1415XvN>cnS zk_qU%P}4fO0v>J{Zw9o*)JF-CPA!KcpFR1Pn(l@*bKh=1_!ZRWb?FoG5a22cVG<$5 z0|%Qj7p@n}=Hrkk`BkD99I57h7_+lQ-AZ-?fETz5E~q(= z!!d%~_yivn82d_pX#M+Y`|`-F^s6-{6}S!?_mFzr<=n>M{{PUq7g-N`hqOcY-y_m= zc#xZEqMPgqc5cu{ag@Tdli5@JlV{xH8J%TA}P<$=Qej`5Hq>_Gzk+NDFM{b*SA6Yydp9VOs1VgIYAcj@1BIt< zXz@=NF2DLCC>`r|^h-z5@eIEh>Vnjh+|-6M@nuC!oc*856_8#_6jL|rKLYu=)Ew4+ z*XiJVgHrKl?=0wjQ)aeNu2^jkUW>@Hei_S;nuA%RRe49V`VM;8SxUBxpZPe>l9ZA{YS(NU; zhnP(vSd1kYiV^KQ02>XpH6u}Xk)wrk`+SxNxC73cSAefm+V!<`c^b#A9NaTn45bEq zkRYp$U%h-|^9P*syb!eKG!QC-$;IS9MdE^@-`WRSzTp+8M9zqJCUsoPC-3Tr+qbkO z$o;ra-wGjC64H8m{(*FVitg+LQKH+96D4!FREFb|Scex)lw()`rHV$WMdUJNe3E}`->+?@(FDYcZt1#>wXwgHzQ6{p% zTY#PF?iBGE7<=u*`SFt0Lw0HX!oh85UlzQH{;k~&JH?kPJzdQX=gAmX40n@#()wBu zSllJ`lX^ZF9!&n2{1443>o2BzK(6sGDQ?n~RYk_ih&{?TJNBH*Eq`73g$F~WrJz{` zce}LL0;S^ZMb&nKyWR#(_t{VguBs~LOSLX&q*$M&haRh5HO5G%C&MvDmi{a@PM;Zq z)h;XzD;Cshu#GG)RsptBTJvnQHC(-#7@G7B`iqJMl=F%g zD7I#-8sWBC_kJC!{tU)rGSX-nt`B$M86ARc$^oIWRNOCMU!X+%PKM$X`mI~kxxaKB znBMvsb8nZ)0}JBmidn3FUeG@ZcdpwZy_4oi*b{&c?T^HaVC|`tnlo?1SjRKLNPk{gDWT+_1fio|Ic{5kU=X{rvm3 zZIZ6BO4vMQdqO`~Ef~j4Z?cQ(+Ff$wxGAlyMBqd}_S__(_xM@v-fTM;$Q^HhR@PU= zE|8KP1IM4s;)*-+Z@m25>p^N(PgHJsq+a!8`ezsTQ3Np0+k4Mtdkgu z^}tg`-YMQKuuO>dsJQkgyjabt1)2OM)|R(}hto4zSIj5V;^@PYtIwI&4#+%;&Kf)o z7)jrDgZ%f?x$UCa=&~<9SHq{ZhxKx!b+ft~!I?(H$&BMOox4KuOo95gl<%5AIg+is zd=%?6ZOr(k=S0U?!*k{1h5q3O_ZrYo5Hq#Sl|1?L+WU%}6JI(orD)*qq-300E63z? z#iM){^ff?RwehBsE3Uh)}m z74!C`a^?2x1@?-i<#cI?a=RcP4Xx$88l&B!g`Nm)Fo$Fcf!VX@0y$z7EVz~OXbALP zyfX0m-nf+4I&E=bsAjk~l_2g3i}1e%qO!KkQ@Ij*%HbGO)w=i^^5FvkHIIee`4l@J zN(eR%MpMiipJjP0Cxd|&4n@b?>6{Ue05+A0q?xd^oCpYNXpePmO#{q`vISfX)oT82 zc+d5gPn5-?9wBmlt3pk*z*hj`X#ycn4?KJY!|++>4l2@t>FhVEjPeFAhW%k5Vkm2~ zbcy`#HFb1XOYOKAcKGGN*GG%skMBnYSL@4d#@wS$CLny@9vSEwSCUSW;OHk%_<>T$ z7HwfvT&)@WQFkIm_dH-5Csjc|H+OBX6;F-rR3wuTudV;|_Oc(#-}UUgloD_-!aH>L z-NF)hJ|F-%gI?Y8Jvo7qXRG7UV5l2_yAHF93IhsP-b`cH*wlEz^Qi99$$*D?10PGQ zCkYPA5Hltd=c+>(bWIfjJP@1Obe?Gx$=qVDe)rPM+5sw)!8F3K7T{OMLFj_+>SX>F zTT-48YC1?q1IV|?OSG8?IGXAN;&q~nz?z0#i+qM9P~U@BNG1FyO9#kvk>T>G=#)_^ zj!fMlH{X;+ONmr!LsJx(j*b2&WMpJ+s&cN;7Tyu8gf>RT2kOR+DBzZr7=m-v-UheM zgj$|(0HN;F)qrlz6$FyVsy6e02`M!$<1L&Bz z+b!=_(#ur8?I=h&thJP2c+^S%)lEi*8fSaPs>Or&i1kF^p9QX&8C;)E+S__7fCh{W zSpW930L|8eV$Pa=LO*oao@VWHUr>MSl`x%iydJaFA!rB6u0`Jo5337p0UZNmSb{=o z*%W(>6W|^!F&8DUAC~&Vo2D?gE{V0S3{B;atoXLUNo9J? z0AWHot1HHimnr%xGf~-qSOO6>z*MtHe(EIN3<7@k-U&gFD+Xq}Ua*o~(!1kApC zO+-7O=jP#uq4B~*JwPs<`_;tw%;J3m{g-9xU(RBU&q^x&eSc@Ik<8NR$i0+>JBKgT zPqjfRC3Q3V=4q|BVK-yVuyUMByvXqR1a4^k&=*MqJ_v2b7I+El z1&0}s^tJ?^uXsz@oZ9j4x^n+$X$>D_nE$4#I-;EJG6wc;Jy@i$hSA&JVNoE;;UpDo l!Q;r<<-MKrq~`aIaqoP9xRgPV&EKy+z~U_0tkM({{ePlYU?u&Z`mr_kcwz5Nh&g=McJ3E!;CE1E0ryV5Ro;>nvty8 zA{omJnn+{p4952Let*87zvA;auXFF~{<`_uPA4&sV%P>LMpp1PTBEIL*yWZ2%{t3Pe;FXZ3XmxI8(D_g57_$Zil~sY6d4T}-hu9_Wqp4C0AMO{-e2$W~1A}=8 z?24)=?B)4HUDo_oXckN%okP)HFJjaB4*3_SNpKaf;yPT}KqfS{2x7`d{0xbPErH%h zh`mQJ03DaATP9aP!}a4$fY#``NI~M6&RljED)8z}hhWxrNbxIBlTxG^j z!X>$3AQQ&I%_5mRECOjaGwR-GHmde})^)t-3_~aFM1G_L#mpCNdcLqr(RKjv3R}(z zG2^yBftMYh;H3a#-slaj|5$BX9+{PTv&NtR*P-L?l21FGTG`$H9~##p%VE!uR>=NG zc&auxVl!1_lP%uX71AJvlz(wLYl?63oLd~dqjZRrU#UEWw8J6Yn-7L~T$$tjeAQiW z9$XG5Hu>rxFBnzgd6ho#^gE5pY>U$dTCRN85Y1tQQ0=Pn{?7OJ10x9Xk!>P2f(f^f zILd}5--N;Po4*25F|J3ywIv+R@rfcYNj}R-sXrH2TFAiK{jFGG(ru1p=w$wR;IXQwAX*S~oiEK{g;kZPW;YE|!QY|g^2`dMS{&1Fr zkf?!sj~m)xO3v`hh4KQRJ&&Q!=X1HNq8T_Sg2P^B&rZX{VQUNc9O(K+B_Z4hiTH7M zW7K5Y!Ec5xD~B9zFlKUWG_Rd)xTK7U#hRGhp51T++e6oS{gT^?3s~>V4?6{zchhc_ z3UBb_W2U+~guMsG-g=@#aWPSFypk)5jIUTxFiM zycGZzbxQuCTnvH*kv=E=LsRnltLbhgm$=ttS1IzU0)1t~4(XE>bHVwJpAPKOqoI-# zrdc{yo0R7Qx%~ZQl{UPa?gmxo#ZWM|vNHNxl@8NLksfn5Ek>C${w=x~pekl%gfwaLwWspL{af)?f zTOBmhTyU&3;}QeF&VLwhJ>Dezu>~P zc+$aFxKDWKj-CmD(v`}uH|ts*SefX@lyrc<%~WE6tHU#dv;y+LlA@cTgl8J!u@@u6 z@@fvJdC)1TvBa$QT@ck`rUxF**7w4Yh0!vZUsGu%Lm(cl(l#QPpmoOH3JC>FMe07G zq0kl#K+GLndyoOx8{t9g8JiLs#`pH8JWqR_ZM%J!Yr>cp>95<^#=FWQfzPm%q;5B+ z0>}ul8+l+gRaHV$$tsq5|MU;?AJ~m-XNxjW3U6JH2k`tOXAqi)yGI@^uA&dQ% zZCJIe7{qK>+p_F)Sqy-GC!x-5MgogsP6lwiUH`N^a7*LKPdO{!4L^_^;goe*e}3s( z0i~~@V#)#L*W~2F?}&N*IQ)0a4Z1$uTU)p7^Mq&IM6K6d*$vpX2+L*+$9vY0=7?$b zxdD4R`8~74HMWsx#*goNSp#(_;z`UT-GuGxoUl-){JNk1rf)aSKE!W`#m`t#v6V!u zgn>fufpkVprL(KqSkhl*Z+yRQosF)bEiV<#K8hOr>yQ1@7Xg>g3EjKwLB7)(9$3%X z$G30OD&Z2Nh{;v5!}oF4fUu0TM%&2F-6aS1+fqu3cn;K4k4-#kkB|BO?bZtcTygp+ zB|R0)0x`)UVEm;Fwx~Vt*6ZV3k5Xcj6_=(X2y*8M&NGz^?Jr>Jutu8idcHpesED^^ znM9MV2AcX%oppm45TS9yYBtteX?1liAe($}l8Mrk|YY*cFUp@Yl5_|Ih%+ z5^dz*^BpQ&l8;Le-Z+E?J1_|}dtK>`0HCSg@u z*e9pUpX4zkcJ~*%3c8N=D_*8f&2puu6>riMeA#MG3E+*kYt|0Dnl;U^u0x`IJLnY* zjELAyFaL6=ihd=uwgnc)F;a_ZKEBsA_UuVc$NS1$GwozcE)2-hGS_c!*V9@%u`#?lhbMR;p$MXpbUS7*AsAt5?3(xQtcatZ zK;B-KhX__vb(?F4Q0GloBJ>|QvdJoM?lDbgsR3iM@a;Z3?cA&4wtslYkr80ETZHkc z9*>q7Q7<0~XHK7PK#yo@cBi@smopq(-%`e-KH4Qx-~rbHu}dW58QqJ{;3Inef@=x4 zI)BgQYXff|j7xg1Qx_M8s)u`0@M0d&aKAfD6qe?B3THxh84PWrQX5xII()>h>b|f$ zpKR+*4#vbnsS3H{v&>IrrO}Xrp{O`p?Q{I%z{XPHRAc7mQ~rVVZ80t_sel;~R{!fE znoWNU9=P1`jx=A?#Ye1fm8**6`|yK3jKQSofyZy4XkM$FK?NExjqO&YVea7N(7$X$ zbR{k3PT@a2CJt_@Dead-55GO?f3gVr{BdM(wXV#1%q{YCJlyB~k-m;m1@SZyhI$5p z9ViBGQ5QzVRGUDbbtaN^E&{f(lI64ub2s){aFm!11riDV*6MFh58H{nU5}0{$^Hi; zJVW(-UYp)>>|Lx|%+y^DwKhz`tPS-85#6Rh0)ckL)U$^na{7 z@VVG(5^ui@Hf1odF537(mlR>ZBhjf%rT+ zPUdZ~CgvIZM_wUkJAw%w}x9jc8!TL)0!EfOi*AMUgP00QdmWDhdxHH4HGc<~J zIVYb|Vj$~E#d*)1>gzKQFOMaAy}BVVo}IK&7ZMB zx!9l*+ek@g>FsKVCTu!A+bt50<5zR%LvhtB47 zphLoLmz-;H4@2#)g8=!k#zLI#UMqFnH)&}~tj#&gW_Q99mQw+L7dU5Tu)W%;@9Qi9 z>QGi--TSZnR2z4)8B5wJy^vu$s+IRc0ll#|LNt!?I`me%fGty24eDN4Xl+O{(+NPj z1ygVh>zf*$Pk&fEX-3AP^1w$s1y_e7lBxzgSu6?iXt=l939t1dNMV&Hw?hI}<+!vx zKuXRw@aAWBEW)iT2xma>qG11B|GnfLf43m`S%SD z3d3^-2o=m;T`_XFO4d`JiOd4T*vl!w_t?SMNPGOr712xew$!m3PP4`3g2iVGiU!9* z&w=GY2O}!evGB%RQa5rA7s5%`YA&A$+(`a%B< z)4%^Wyf-xKA)KjJ=y>(k$Cki3nVk)wxAEYIGA3p>sG^i;f$cIw3$H&^I7dNHU=sw$d)j7 zh|(sSuhT>1EWU{wVQLz{XV1iYPIvxnNv=>Vu3kdkB_SVNJ(KJiSF;#9T-Gc6A9!kU z?a4i1-1H;R$hx=;;1@G7Jsm?|a=U>2b+qZz`aN9sgsIyFSp6r%%!9oq%tbmjY#K7P z-Gux{jUMaKw>DF`W{3tTZ|SIDqX6v)w4@1rITXmow6pv9GTr+NsJ`V>Zv++iD5MFK z@5#Rx6sk|u-Qs__;w5Q)X2-Ad+QXxzHC&)U-n+`G@G_e77|5&TV3EucN^AXqK{AmK pCn+FvZU>f5ukGw-)qi%3dglGbB=rNWkH7i=^YbXv3KMkH{{f&jC-?vW literal 0 HcmV?d00001 diff --git a/css/smoothness/jquery-ui-1.7.2.custom.css b/css/smoothness/jquery-ui-1.7.2.custom.css new file mode 100755 index 00000000..444486b9 --- /dev/null +++ b/css/smoothness/jquery-ui-1.7.2.custom.css @@ -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/css/ticket.css b/css/ticket.css new file mode 100644 index 00000000..3e6d67fb --- /dev/null +++ b/css/ticket.css @@ -0,0 +1,134 @@ +/*