@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Move web application classes into "phabricator/"

Summary: Ref T13395. Companion change to D20773.

Test Plan: See D20773.

Maniphest Tasks: T13395

Differential Revision: https://secure.phabricator.com/D20774

+17837 -1
+2 -1
.arclint
··· 1 1 { 2 2 "exclude": [ 3 3 "(^externals/)", 4 - "(^webroot/rsrc/externals/(?!javelin/))" 4 + "(^webroot/rsrc/externals/(?!javelin/))", 5 + "(/__tests__/data/)" 5 6 ], 6 7 "linters": { 7 8 "chmod": {
+236
src/__phutil_library_map__.php
··· 176 176 'Aphront400Response' => 'aphront/response/Aphront400Response.php', 177 177 'Aphront403Response' => 'aphront/response/Aphront403Response.php', 178 178 'Aphront404Response' => 'aphront/response/Aphront404Response.php', 179 + 'AphrontAccessDeniedQueryException' => 'infrastructure/storage/exception/AphrontAccessDeniedQueryException.php', 179 180 'AphrontAjaxResponse' => 'aphront/response/AphrontAjaxResponse.php', 180 181 'AphrontApplicationConfiguration' => 'aphront/configuration/AphrontApplicationConfiguration.php', 181 182 'AphrontBarView' => 'view/widget/bars/AphrontBarView.php', 183 + 'AphrontBaseMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php', 182 184 'AphrontBoolHTTPParameterType' => 'aphront/httpparametertype/AphrontBoolHTTPParameterType.php', 183 185 'AphrontCalendarEventView' => 'applications/calendar/view/AphrontCalendarEventView.php', 186 + 'AphrontCharacterSetQueryException' => 'infrastructure/storage/exception/AphrontCharacterSetQueryException.php', 187 + 'AphrontConnectionLostQueryException' => 'infrastructure/storage/exception/AphrontConnectionLostQueryException.php', 188 + 'AphrontConnectionQueryException' => 'infrastructure/storage/exception/AphrontConnectionQueryException.php', 184 189 'AphrontController' => 'aphront/AphrontController.php', 190 + 'AphrontCountQueryException' => 'infrastructure/storage/exception/AphrontCountQueryException.php', 185 191 'AphrontCursorPagerView' => 'view/control/AphrontCursorPagerView.php', 192 + 'AphrontDatabaseConnection' => 'infrastructure/storage/connection/AphrontDatabaseConnection.php', 193 + 'AphrontDatabaseTableRef' => 'infrastructure/storage/xsprintf/AphrontDatabaseTableRef.php', 194 + 'AphrontDatabaseTableRefInterface' => 'infrastructure/storage/xsprintf/AphrontDatabaseTableRefInterface.php', 195 + 'AphrontDatabaseTransactionState' => 'infrastructure/storage/connection/AphrontDatabaseTransactionState.php', 196 + 'AphrontDeadlockQueryException' => 'infrastructure/storage/exception/AphrontDeadlockQueryException.php', 186 197 'AphrontDialogResponse' => 'aphront/response/AphrontDialogResponse.php', 187 198 'AphrontDialogView' => 'view/AphrontDialogView.php', 199 + 'AphrontDuplicateKeyQueryException' => 'infrastructure/storage/exception/AphrontDuplicateKeyQueryException.php', 188 200 'AphrontEpochHTTPParameterType' => 'aphront/httpparametertype/AphrontEpochHTTPParameterType.php', 189 201 'AphrontException' => 'aphront/exception/AphrontException.php', 190 202 'AphrontFileHTTPParameterType' => 'aphront/httpparametertype/AphrontFileHTTPParameterType.php', ··· 217 229 'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php', 218 230 'AphrontHTTPSinkTestCase' => 'aphront/sink/__tests__/AphrontHTTPSinkTestCase.php', 219 231 'AphrontIntHTTPParameterType' => 'aphront/httpparametertype/AphrontIntHTTPParameterType.php', 232 + 'AphrontInvalidCredentialsQueryException' => 'infrastructure/storage/exception/AphrontInvalidCredentialsQueryException.php', 233 + 'AphrontIsolatedDatabaseConnection' => 'infrastructure/storage/connection/AphrontIsolatedDatabaseConnection.php', 220 234 'AphrontIsolatedDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontIsolatedDatabaseConnectionTestCase.php', 221 235 'AphrontIsolatedHTTPSink' => 'aphront/sink/AphrontIsolatedHTTPSink.php', 222 236 'AphrontJSONResponse' => 'aphront/response/AphrontJSONResponse.php', ··· 224 238 'AphrontKeyboardShortcutsAvailableView' => 'view/widget/AphrontKeyboardShortcutsAvailableView.php', 225 239 'AphrontListFilterView' => 'view/layout/AphrontListFilterView.php', 226 240 'AphrontListHTTPParameterType' => 'aphront/httpparametertype/AphrontListHTTPParameterType.php', 241 + 'AphrontLockTimeoutQueryException' => 'infrastructure/storage/exception/AphrontLockTimeoutQueryException.php', 227 242 'AphrontMalformedRequestException' => 'aphront/exception/AphrontMalformedRequestException.php', 228 243 'AphrontMoreView' => 'view/layout/AphrontMoreView.php', 229 244 'AphrontMultiColumnView' => 'view/layout/AphrontMultiColumnView.php', 245 + 'AphrontMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLDatabaseConnection.php', 230 246 'AphrontMySQLDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontMySQLDatabaseConnectionTestCase.php', 247 + 'AphrontMySQLiDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php', 248 + 'AphrontNotSupportedQueryException' => 'infrastructure/storage/exception/AphrontNotSupportedQueryException.php', 231 249 'AphrontNullView' => 'view/AphrontNullView.php', 250 + 'AphrontObjectMissingQueryException' => 'infrastructure/storage/exception/AphrontObjectMissingQueryException.php', 232 251 'AphrontPHIDHTTPParameterType' => 'aphront/httpparametertype/AphrontPHIDHTTPParameterType.php', 233 252 'AphrontPHIDListHTTPParameterType' => 'aphront/httpparametertype/AphrontPHIDListHTTPParameterType.php', 234 253 'AphrontPHPHTTPSink' => 'aphront/sink/AphrontPHPHTTPSink.php', 235 254 'AphrontPageView' => 'view/page/AphrontPageView.php', 255 + 'AphrontParameterQueryException' => 'infrastructure/storage/exception/AphrontParameterQueryException.php', 236 256 'AphrontPlainTextResponse' => 'aphront/response/AphrontPlainTextResponse.php', 237 257 'AphrontProgressBarView' => 'view/widget/bars/AphrontProgressBarView.php', 238 258 'AphrontProjectListHTTPParameterType' => 'aphront/httpparametertype/AphrontProjectListHTTPParameterType.php', 239 259 'AphrontProxyResponse' => 'aphront/response/AphrontProxyResponse.php', 260 + 'AphrontQueryException' => 'infrastructure/storage/exception/AphrontQueryException.php', 261 + 'AphrontQueryTimeoutQueryException' => 'infrastructure/storage/exception/AphrontQueryTimeoutQueryException.php', 262 + 'AphrontRecoverableQueryException' => 'infrastructure/storage/exception/AphrontRecoverableQueryException.php', 240 263 'AphrontRedirectResponse' => 'aphront/response/AphrontRedirectResponse.php', 241 264 'AphrontRedirectResponseTestCase' => 'aphront/response/__tests__/AphrontRedirectResponseTestCase.php', 242 265 'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php', ··· 247 270 'AphrontResponseProducerInterface' => 'aphront/interface/AphrontResponseProducerInterface.php', 248 271 'AphrontRoutingMap' => 'aphront/site/AphrontRoutingMap.php', 249 272 'AphrontRoutingResult' => 'aphront/site/AphrontRoutingResult.php', 273 + 'AphrontSchemaQueryException' => 'infrastructure/storage/exception/AphrontSchemaQueryException.php', 250 274 'AphrontSelectHTTPParameterType' => 'aphront/httpparametertype/AphrontSelectHTTPParameterType.php', 251 275 'AphrontSideNavFilterView' => 'view/layout/AphrontSideNavFilterView.php', 252 276 'AphrontSite' => 'aphront/site/AphrontSite.php', ··· 5512 5536 'PhrictionTransactionComment' => 'applications/phriction/storage/PhrictionTransactionComment.php', 5513 5537 'PhrictionTransactionEditor' => 'applications/phriction/editor/PhrictionTransactionEditor.php', 5514 5538 'PhrictionTransactionQuery' => 'applications/phriction/query/PhrictionTransactionQuery.php', 5539 + 'PhutilAmazonAuthAdapter' => 'applications/auth/adapter/PhutilAmazonAuthAdapter.php', 5540 + 'PhutilAsanaAuthAdapter' => 'applications/auth/adapter/PhutilAsanaAuthAdapter.php', 5541 + 'PhutilAuthAdapter' => 'applications/auth/adapter/PhutilAuthAdapter.php', 5542 + 'PhutilAuthConfigurationException' => 'applications/auth/exception/PhutilAuthConfigurationException.php', 5543 + 'PhutilAuthCredentialException' => 'applications/auth/exception/PhutilAuthCredentialException.php', 5544 + 'PhutilAuthException' => 'applications/auth/exception/PhutilAuthException.php', 5545 + 'PhutilAuthUserAbortedException' => 'applications/auth/exception/PhutilAuthUserAbortedException.php', 5546 + 'PhutilBitbucketAuthAdapter' => 'applications/auth/adapter/PhutilBitbucketAuthAdapter.php', 5547 + 'PhutilCLikeCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilCLikeCodeSnippetContextFreeGrammar.php', 5548 + 'PhutilCalendarAbsoluteDateTime' => 'applications/calendar/parser/data/PhutilCalendarAbsoluteDateTime.php', 5549 + 'PhutilCalendarContainerNode' => 'applications/calendar/parser/data/PhutilCalendarContainerNode.php', 5550 + 'PhutilCalendarDateTime' => 'applications/calendar/parser/data/PhutilCalendarDateTime.php', 5551 + 'PhutilCalendarDateTimeTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarDateTimeTestCase.php', 5552 + 'PhutilCalendarDocumentNode' => 'applications/calendar/parser/data/PhutilCalendarDocumentNode.php', 5553 + 'PhutilCalendarDuration' => 'applications/calendar/parser/data/PhutilCalendarDuration.php', 5554 + 'PhutilCalendarEventNode' => 'applications/calendar/parser/data/PhutilCalendarEventNode.php', 5555 + 'PhutilCalendarNode' => 'applications/calendar/parser/data/PhutilCalendarNode.php', 5556 + 'PhutilCalendarProxyDateTime' => 'applications/calendar/parser/data/PhutilCalendarProxyDateTime.php', 5557 + 'PhutilCalendarRawNode' => 'applications/calendar/parser/data/PhutilCalendarRawNode.php', 5558 + 'PhutilCalendarRecurrenceList' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceList.php', 5559 + 'PhutilCalendarRecurrenceRule' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php', 5560 + 'PhutilCalendarRecurrenceRuleTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php', 5561 + 'PhutilCalendarRecurrenceSet' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceSet.php', 5562 + 'PhutilCalendarRecurrenceSource' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceSource.php', 5563 + 'PhutilCalendarRecurrenceTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceTestCase.php', 5564 + 'PhutilCalendarRelativeDateTime' => 'applications/calendar/parser/data/PhutilCalendarRelativeDateTime.php', 5565 + 'PhutilCalendarRootNode' => 'applications/calendar/parser/data/PhutilCalendarRootNode.php', 5566 + 'PhutilCalendarUserNode' => 'applications/calendar/parser/data/PhutilCalendarUserNode.php', 5567 + 'PhutilCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilCodeSnippetContextFreeGrammar.php', 5568 + 'PhutilDisqusAuthAdapter' => 'applications/auth/adapter/PhutilDisqusAuthAdapter.php', 5569 + 'PhutilEmptyAuthAdapter' => 'applications/auth/adapter/PhutilEmptyAuthAdapter.php', 5570 + 'PhutilFacebookAuthAdapter' => 'applications/auth/adapter/PhutilFacebookAuthAdapter.php', 5571 + 'PhutilGitHubAuthAdapter' => 'applications/auth/adapter/PhutilGitHubAuthAdapter.php', 5572 + 'PhutilGoogleAuthAdapter' => 'applications/auth/adapter/PhutilGoogleAuthAdapter.php', 5573 + 'PhutilICSParser' => 'applications/calendar/parser/ics/PhutilICSParser.php', 5574 + 'PhutilICSParserException' => 'applications/calendar/parser/ics/PhutilICSParserException.php', 5575 + 'PhutilICSParserTestCase' => 'applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php', 5576 + 'PhutilICSWriter' => 'applications/calendar/parser/ics/PhutilICSWriter.php', 5577 + 'PhutilICSWriterTestCase' => 'applications/calendar/parser/ics/__tests__/PhutilICSWriterTestCase.php', 5578 + 'PhutilJIRAAuthAdapter' => 'applications/auth/adapter/PhutilJIRAAuthAdapter.php', 5579 + 'PhutilJavaCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilJavaCodeSnippetContextFreeGrammar.php', 5580 + 'PhutilLDAPAuthAdapter' => 'applications/auth/adapter/PhutilLDAPAuthAdapter.php', 5581 + 'PhutilLipsumContextFreeGrammar' => 'infrastructure/lipsum/PhutilLipsumContextFreeGrammar.php', 5582 + 'PhutilOAuth1AuthAdapter' => 'applications/auth/adapter/PhutilOAuth1AuthAdapter.php', 5583 + 'PhutilOAuthAuthAdapter' => 'applications/auth/adapter/PhutilOAuthAuthAdapter.php', 5584 + 'PhutilPHPCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilPHPCodeSnippetContextFreeGrammar.php', 5585 + 'PhutilPhabricatorAuthAdapter' => 'applications/auth/adapter/PhutilPhabricatorAuthAdapter.php', 5586 + 'PhutilQsprintfInterface' => 'infrastructure/storage/xsprintf/PhutilQsprintfInterface.php', 5587 + 'PhutilQueryString' => 'infrastructure/storage/xsprintf/PhutilQueryString.php', 5588 + 'PhutilRealNameContextFreeGrammar' => 'infrastructure/lipsum/PhutilRealNameContextFreeGrammar.php', 5589 + 'PhutilRemarkupBlockInterpreter' => 'infrastructure/markup/blockrule/PhutilRemarkupBlockInterpreter.php', 5590 + 'PhutilRemarkupBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php', 5591 + 'PhutilRemarkupBlockStorage' => 'infrastructure/markup/PhutilRemarkupBlockStorage.php', 5592 + 'PhutilRemarkupBoldRule' => 'infrastructure/markup/markuprule/PhutilRemarkupBoldRule.php', 5593 + 'PhutilRemarkupCodeBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php', 5594 + 'PhutilRemarkupDefaultBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupDefaultBlockRule.php', 5595 + 'PhutilRemarkupDelRule' => 'infrastructure/markup/markuprule/PhutilRemarkupDelRule.php', 5596 + 'PhutilRemarkupDocumentLinkRule' => 'infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php', 5597 + 'PhutilRemarkupEngine' => 'infrastructure/markup/remarkup/PhutilRemarkupEngine.php', 5598 + 'PhutilRemarkupEngineTestCase' => 'infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php', 5599 + 'PhutilRemarkupEscapeRemarkupRule' => 'infrastructure/markup/markuprule/PhutilRemarkupEscapeRemarkupRule.php', 5600 + 'PhutilRemarkupHeaderBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupHeaderBlockRule.php', 5601 + 'PhutilRemarkupHighlightRule' => 'infrastructure/markup/markuprule/PhutilRemarkupHighlightRule.php', 5602 + 'PhutilRemarkupHorizontalRuleBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupHorizontalRuleBlockRule.php', 5603 + 'PhutilRemarkupHyperlinkEngineExtension' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php', 5604 + 'PhutilRemarkupHyperlinkRef' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRef.php', 5605 + 'PhutilRemarkupHyperlinkRule' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRule.php', 5606 + 'PhutilRemarkupInlineBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupInlineBlockRule.php', 5607 + 'PhutilRemarkupInterpreterBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupInterpreterBlockRule.php', 5608 + 'PhutilRemarkupItalicRule' => 'infrastructure/markup/markuprule/PhutilRemarkupItalicRule.php', 5609 + 'PhutilRemarkupLinebreaksRule' => 'infrastructure/markup/markuprule/PhutilRemarkupLinebreaksRule.php', 5610 + 'PhutilRemarkupListBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php', 5611 + 'PhutilRemarkupLiteralBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php', 5612 + 'PhutilRemarkupMonospaceRule' => 'infrastructure/markup/markuprule/PhutilRemarkupMonospaceRule.php', 5613 + 'PhutilRemarkupNoteBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupNoteBlockRule.php', 5614 + 'PhutilRemarkupQuotedBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php', 5615 + 'PhutilRemarkupQuotesBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupQuotesBlockRule.php', 5616 + 'PhutilRemarkupReplyBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupReplyBlockRule.php', 5617 + 'PhutilRemarkupRule' => 'infrastructure/markup/markuprule/PhutilRemarkupRule.php', 5618 + 'PhutilRemarkupSimpleTableBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupSimpleTableBlockRule.php', 5619 + 'PhutilRemarkupTableBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupTableBlockRule.php', 5620 + 'PhutilRemarkupTestInterpreterRule' => 'infrastructure/markup/blockrule/PhutilRemarkupTestInterpreterRule.php', 5621 + 'PhutilRemarkupUnderlineRule' => 'infrastructure/markup/markuprule/PhutilRemarkupUnderlineRule.php', 5622 + 'PhutilSlackAuthAdapter' => 'applications/auth/adapter/PhutilSlackAuthAdapter.php', 5623 + 'PhutilTwitchAuthAdapter' => 'applications/auth/adapter/PhutilTwitchAuthAdapter.php', 5624 + 'PhutilTwitterAuthAdapter' => 'applications/auth/adapter/PhutilTwitterAuthAdapter.php', 5625 + 'PhutilWordPressAuthAdapter' => 'applications/auth/adapter/PhutilWordPressAuthAdapter.php', 5515 5626 'PolicyLockOptionType' => 'applications/policy/config/PolicyLockOptionType.php', 5516 5627 'PonderAddAnswerView' => 'applications/ponder/view/PonderAddAnswerView.php', 5517 5628 'PonderAnswer' => 'applications/ponder/storage/PonderAnswer.php', ··· 5587 5698 'ProjectReplyHandler' => 'applications/project/mail/ProjectReplyHandler.php', 5588 5699 'ProjectSearchConduitAPIMethod' => 'applications/project/conduit/ProjectSearchConduitAPIMethod.php', 5589 5700 'QueryFormattingTestCase' => 'infrastructure/storage/__tests__/QueryFormattingTestCase.php', 5701 + 'QueryFuture' => 'infrastructure/storage/future/QueryFuture.php', 5590 5702 'ReleephAuthorFieldSpecification' => 'applications/releeph/field/specification/ReleephAuthorFieldSpecification.php', 5591 5703 'ReleephBranch' => 'applications/releeph/storage/ReleephBranch.php', 5592 5704 'ReleephBranchAccessController' => 'applications/releeph/controller/branch/ReleephBranchAccessController.php', ··· 5713 5825 'phid_get_subtype' => 'applications/phid/utils.php', 5714 5826 'phid_get_type' => 'applications/phid/utils.php', 5715 5827 'phid_group_by_type' => 'applications/phid/utils.php', 5828 + 'qsprintf' => 'infrastructure/storage/xsprintf/qsprintf.php', 5829 + 'qsprintf_check_scalar_type' => 'infrastructure/storage/xsprintf/qsprintf.php', 5830 + 'qsprintf_check_type' => 'infrastructure/storage/xsprintf/qsprintf.php', 5831 + 'queryfx' => 'infrastructure/storage/xsprintf/queryfx.php', 5832 + 'queryfx_all' => 'infrastructure/storage/xsprintf/queryfx.php', 5833 + 'queryfx_one' => 'infrastructure/storage/xsprintf/queryfx.php', 5716 5834 'require_celerity_resource' => 'applications/celerity/api.php', 5835 + 'vqsprintf' => 'infrastructure/storage/xsprintf/qsprintf.php', 5836 + 'xsprintf_query' => 'infrastructure/storage/xsprintf/qsprintf.php', 5717 5837 ), 5718 5838 'xmap' => array( 5719 5839 'AlmanacAddress' => 'Phobject', ··· 5937 6057 'Aphront400Response' => 'AphrontResponse', 5938 6058 'Aphront403Response' => 'AphrontHTMLResponse', 5939 6059 'Aphront404Response' => 'AphrontHTMLResponse', 6060 + 'AphrontAccessDeniedQueryException' => 'AphrontQueryException', 5940 6061 'AphrontAjaxResponse' => 'AphrontResponse', 5941 6062 'AphrontApplicationConfiguration' => 'Phobject', 5942 6063 'AphrontBarView' => 'AphrontView', 6064 + 'AphrontBaseMySQLDatabaseConnection' => 'AphrontDatabaseConnection', 5943 6065 'AphrontBoolHTTPParameterType' => 'AphrontHTTPParameterType', 5944 6066 'AphrontCalendarEventView' => 'AphrontView', 6067 + 'AphrontCharacterSetQueryException' => 'AphrontQueryException', 6068 + 'AphrontConnectionLostQueryException' => 'AphrontRecoverableQueryException', 6069 + 'AphrontConnectionQueryException' => 'AphrontQueryException', 5945 6070 'AphrontController' => 'Phobject', 6071 + 'AphrontCountQueryException' => 'AphrontQueryException', 5946 6072 'AphrontCursorPagerView' => 'AphrontView', 6073 + 'AphrontDatabaseConnection' => array( 6074 + 'Phobject', 6075 + 'PhutilQsprintfInterface', 6076 + ), 6077 + 'AphrontDatabaseTableRef' => array( 6078 + 'Phobject', 6079 + 'AphrontDatabaseTableRefInterface', 6080 + ), 6081 + 'AphrontDatabaseTransactionState' => 'Phobject', 6082 + 'AphrontDeadlockQueryException' => 'AphrontRecoverableQueryException', 5947 6083 'AphrontDialogResponse' => 'AphrontResponse', 5948 6084 'AphrontDialogView' => array( 5949 6085 'AphrontView', 5950 6086 'AphrontResponseProducerInterface', 5951 6087 ), 6088 + 'AphrontDuplicateKeyQueryException' => 'AphrontQueryException', 5952 6089 'AphrontEpochHTTPParameterType' => 'AphrontHTTPParameterType', 5953 6090 'AphrontException' => 'Exception', 5954 6091 'AphrontFileHTTPParameterType' => 'AphrontHTTPParameterType', ··· 5981 6118 'AphrontHTTPSink' => 'Phobject', 5982 6119 'AphrontHTTPSinkTestCase' => 'PhabricatorTestCase', 5983 6120 'AphrontIntHTTPParameterType' => 'AphrontHTTPParameterType', 6121 + 'AphrontInvalidCredentialsQueryException' => 'AphrontQueryException', 6122 + 'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection', 5984 6123 'AphrontIsolatedDatabaseConnectionTestCase' => 'PhabricatorTestCase', 5985 6124 'AphrontIsolatedHTTPSink' => 'AphrontHTTPSink', 5986 6125 'AphrontJSONResponse' => 'AphrontResponse', ··· 5988 6127 'AphrontKeyboardShortcutsAvailableView' => 'AphrontView', 5989 6128 'AphrontListFilterView' => 'AphrontView', 5990 6129 'AphrontListHTTPParameterType' => 'AphrontHTTPParameterType', 6130 + 'AphrontLockTimeoutQueryException' => 'AphrontRecoverableQueryException', 5991 6131 'AphrontMalformedRequestException' => 'AphrontException', 5992 6132 'AphrontMoreView' => 'AphrontView', 5993 6133 'AphrontMultiColumnView' => 'AphrontView', 6134 + 'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', 5994 6135 'AphrontMySQLDatabaseConnectionTestCase' => 'PhabricatorTestCase', 6136 + 'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', 6137 + 'AphrontNotSupportedQueryException' => 'AphrontQueryException', 5995 6138 'AphrontNullView' => 'AphrontView', 6139 + 'AphrontObjectMissingQueryException' => 'AphrontQueryException', 5996 6140 'AphrontPHIDHTTPParameterType' => 'AphrontHTTPParameterType', 5997 6141 'AphrontPHIDListHTTPParameterType' => 'AphrontListHTTPParameterType', 5998 6142 'AphrontPHPHTTPSink' => 'AphrontHTTPSink', 5999 6143 'AphrontPageView' => 'AphrontView', 6144 + 'AphrontParameterQueryException' => 'AphrontQueryException', 6000 6145 'AphrontPlainTextResponse' => 'AphrontResponse', 6001 6146 'AphrontProgressBarView' => 'AphrontBarView', 6002 6147 'AphrontProjectListHTTPParameterType' => 'AphrontListHTTPParameterType', ··· 6004 6149 'AphrontResponse', 6005 6150 'AphrontResponseProducerInterface', 6006 6151 ), 6152 + 'AphrontQueryException' => 'Exception', 6153 + 'AphrontQueryTimeoutQueryException' => 'AphrontRecoverableQueryException', 6154 + 'AphrontRecoverableQueryException' => 'AphrontQueryException', 6007 6155 'AphrontRedirectResponse' => 'AphrontResponse', 6008 6156 'AphrontRedirectResponseTestCase' => 'PhabricatorTestCase', 6009 6157 'AphrontReloadResponse' => 'AphrontRedirectResponse', ··· 6013 6161 'AphrontResponse' => 'Phobject', 6014 6162 'AphrontRoutingMap' => 'Phobject', 6015 6163 'AphrontRoutingResult' => 'Phobject', 6164 + 'AphrontSchemaQueryException' => 'AphrontQueryException', 6016 6165 'AphrontSelectHTTPParameterType' => 'AphrontHTTPParameterType', 6017 6166 'AphrontSideNavFilterView' => 'AphrontView', 6018 6167 'AphrontSite' => 'Phobject', ··· 12169 12318 'PhrictionTransactionComment' => 'PhabricatorApplicationTransactionComment', 12170 12319 'PhrictionTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 12171 12320 'PhrictionTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 12321 + 'PhutilAmazonAuthAdapter' => 'PhutilOAuthAuthAdapter', 12322 + 'PhutilAsanaAuthAdapter' => 'PhutilOAuthAuthAdapter', 12323 + 'PhutilAuthAdapter' => 'Phobject', 12324 + 'PhutilAuthConfigurationException' => 'PhutilAuthException', 12325 + 'PhutilAuthCredentialException' => 'PhutilAuthException', 12326 + 'PhutilAuthException' => 'Exception', 12327 + 'PhutilAuthUserAbortedException' => 'PhutilAuthException', 12328 + 'PhutilBitbucketAuthAdapter' => 'PhutilOAuth1AuthAdapter', 12329 + 'PhutilCLikeCodeSnippetContextFreeGrammar' => 'PhutilCodeSnippetContextFreeGrammar', 12330 + 'PhutilCalendarAbsoluteDateTime' => 'PhutilCalendarDateTime', 12331 + 'PhutilCalendarContainerNode' => 'PhutilCalendarNode', 12332 + 'PhutilCalendarDateTime' => 'Phobject', 12333 + 'PhutilCalendarDateTimeTestCase' => 'PhutilTestCase', 12334 + 'PhutilCalendarDocumentNode' => 'PhutilCalendarContainerNode', 12335 + 'PhutilCalendarDuration' => 'Phobject', 12336 + 'PhutilCalendarEventNode' => 'PhutilCalendarContainerNode', 12337 + 'PhutilCalendarNode' => 'Phobject', 12338 + 'PhutilCalendarProxyDateTime' => 'PhutilCalendarDateTime', 12339 + 'PhutilCalendarRawNode' => 'PhutilCalendarContainerNode', 12340 + 'PhutilCalendarRecurrenceList' => 'PhutilCalendarRecurrenceSource', 12341 + 'PhutilCalendarRecurrenceRule' => 'PhutilCalendarRecurrenceSource', 12342 + 'PhutilCalendarRecurrenceRuleTestCase' => 'PhutilTestCase', 12343 + 'PhutilCalendarRecurrenceSet' => 'Phobject', 12344 + 'PhutilCalendarRecurrenceSource' => 'Phobject', 12345 + 'PhutilCalendarRecurrenceTestCase' => 'PhutilTestCase', 12346 + 'PhutilCalendarRelativeDateTime' => 'PhutilCalendarProxyDateTime', 12347 + 'PhutilCalendarRootNode' => 'PhutilCalendarContainerNode', 12348 + 'PhutilCalendarUserNode' => 'PhutilCalendarNode', 12349 + 'PhutilCodeSnippetContextFreeGrammar' => 'PhutilContextFreeGrammar', 12350 + 'PhutilDisqusAuthAdapter' => 'PhutilOAuthAuthAdapter', 12351 + 'PhutilEmptyAuthAdapter' => 'PhutilAuthAdapter', 12352 + 'PhutilFacebookAuthAdapter' => 'PhutilOAuthAuthAdapter', 12353 + 'PhutilGitHubAuthAdapter' => 'PhutilOAuthAuthAdapter', 12354 + 'PhutilGoogleAuthAdapter' => 'PhutilOAuthAuthAdapter', 12355 + 'PhutilICSParser' => 'Phobject', 12356 + 'PhutilICSParserException' => 'Exception', 12357 + 'PhutilICSParserTestCase' => 'PhutilTestCase', 12358 + 'PhutilICSWriter' => 'Phobject', 12359 + 'PhutilICSWriterTestCase' => 'PhutilTestCase', 12360 + 'PhutilJIRAAuthAdapter' => 'PhutilOAuth1AuthAdapter', 12361 + 'PhutilJavaCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar', 12362 + 'PhutilLDAPAuthAdapter' => 'PhutilAuthAdapter', 12363 + 'PhutilLipsumContextFreeGrammar' => 'PhutilContextFreeGrammar', 12364 + 'PhutilOAuth1AuthAdapter' => 'PhutilAuthAdapter', 12365 + 'PhutilOAuthAuthAdapter' => 'PhutilAuthAdapter', 12366 + 'PhutilPHPCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar', 12367 + 'PhutilPhabricatorAuthAdapter' => 'PhutilOAuthAuthAdapter', 12368 + 'PhutilQueryString' => 'Phobject', 12369 + 'PhutilRealNameContextFreeGrammar' => 'PhutilContextFreeGrammar', 12370 + 'PhutilRemarkupBlockInterpreter' => 'Phobject', 12371 + 'PhutilRemarkupBlockRule' => 'Phobject', 12372 + 'PhutilRemarkupBlockStorage' => 'Phobject', 12373 + 'PhutilRemarkupBoldRule' => 'PhutilRemarkupRule', 12374 + 'PhutilRemarkupCodeBlockRule' => 'PhutilRemarkupBlockRule', 12375 + 'PhutilRemarkupDefaultBlockRule' => 'PhutilRemarkupBlockRule', 12376 + 'PhutilRemarkupDelRule' => 'PhutilRemarkupRule', 12377 + 'PhutilRemarkupDocumentLinkRule' => 'PhutilRemarkupRule', 12378 + 'PhutilRemarkupEngine' => 'PhutilMarkupEngine', 12379 + 'PhutilRemarkupEngineTestCase' => 'PhutilTestCase', 12380 + 'PhutilRemarkupEscapeRemarkupRule' => 'PhutilRemarkupRule', 12381 + 'PhutilRemarkupHeaderBlockRule' => 'PhutilRemarkupBlockRule', 12382 + 'PhutilRemarkupHighlightRule' => 'PhutilRemarkupRule', 12383 + 'PhutilRemarkupHorizontalRuleBlockRule' => 'PhutilRemarkupBlockRule', 12384 + 'PhutilRemarkupHyperlinkEngineExtension' => 'Phobject', 12385 + 'PhutilRemarkupHyperlinkRef' => 'Phobject', 12386 + 'PhutilRemarkupHyperlinkRule' => 'PhutilRemarkupRule', 12387 + 'PhutilRemarkupInlineBlockRule' => 'PhutilRemarkupBlockRule', 12388 + 'PhutilRemarkupInterpreterBlockRule' => 'PhutilRemarkupBlockRule', 12389 + 'PhutilRemarkupItalicRule' => 'PhutilRemarkupRule', 12390 + 'PhutilRemarkupLinebreaksRule' => 'PhutilRemarkupRule', 12391 + 'PhutilRemarkupListBlockRule' => 'PhutilRemarkupBlockRule', 12392 + 'PhutilRemarkupLiteralBlockRule' => 'PhutilRemarkupBlockRule', 12393 + 'PhutilRemarkupMonospaceRule' => 'PhutilRemarkupRule', 12394 + 'PhutilRemarkupNoteBlockRule' => 'PhutilRemarkupBlockRule', 12395 + 'PhutilRemarkupQuotedBlockRule' => 'PhutilRemarkupBlockRule', 12396 + 'PhutilRemarkupQuotesBlockRule' => 'PhutilRemarkupQuotedBlockRule', 12397 + 'PhutilRemarkupReplyBlockRule' => 'PhutilRemarkupQuotedBlockRule', 12398 + 'PhutilRemarkupRule' => 'Phobject', 12399 + 'PhutilRemarkupSimpleTableBlockRule' => 'PhutilRemarkupBlockRule', 12400 + 'PhutilRemarkupTableBlockRule' => 'PhutilRemarkupBlockRule', 12401 + 'PhutilRemarkupTestInterpreterRule' => 'PhutilRemarkupBlockInterpreter', 12402 + 'PhutilRemarkupUnderlineRule' => 'PhutilRemarkupRule', 12403 + 'PhutilSlackAuthAdapter' => 'PhutilOAuthAuthAdapter', 12404 + 'PhutilTwitchAuthAdapter' => 'PhutilOAuthAuthAdapter', 12405 + 'PhutilTwitterAuthAdapter' => 'PhutilOAuth1AuthAdapter', 12406 + 'PhutilWordPressAuthAdapter' => 'PhutilOAuthAuthAdapter', 12172 12407 'PolicyLockOptionType' => 'PhabricatorConfigJSONOptionType', 12173 12408 'PonderAddAnswerView' => 'AphrontView', 12174 12409 'PonderAnswer' => array( ··· 12265 12500 'ProjectReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler', 12266 12501 'ProjectSearchConduitAPIMethod' => 'PhabricatorSearchEngineAPIMethod', 12267 12502 'QueryFormattingTestCase' => 'PhabricatorTestCase', 12503 + 'QueryFuture' => 'Future', 12268 12504 'ReleephAuthorFieldSpecification' => 'ReleephFieldSpecification', 12269 12505 'ReleephBranch' => array( 12270 12506 'ReleephDAO',
+80
src/applications/auth/adapter/PhutilAmazonAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Amazon OAuth2. 5 + */ 6 + final class PhutilAmazonAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'amazon'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'amazon.com'; 14 + } 15 + 16 + public function getAccountID() { 17 + return $this->getOAuthAccountData('user_id'); 18 + } 19 + 20 + public function getAccountEmail() { 21 + return $this->getOAuthAccountData('email'); 22 + } 23 + 24 + public function getAccountName() { 25 + return null; 26 + } 27 + 28 + public function getAccountImageURI() { 29 + return null; 30 + } 31 + 32 + public function getAccountURI() { 33 + return null; 34 + } 35 + 36 + public function getAccountRealName() { 37 + return $this->getOAuthAccountData('name'); 38 + } 39 + 40 + protected function getAuthenticateBaseURI() { 41 + return 'https://www.amazon.com/ap/oa'; 42 + } 43 + 44 + protected function getTokenBaseURI() { 45 + return 'https://api.amazon.com/auth/o2/token'; 46 + } 47 + 48 + public function getScope() { 49 + return 'profile'; 50 + } 51 + 52 + public function getExtraAuthenticateParameters() { 53 + return array( 54 + 'response_type' => 'code', 55 + ); 56 + } 57 + 58 + public function getExtraTokenParameters() { 59 + return array( 60 + 'grant_type' => 'authorization_code', 61 + ); 62 + } 63 + 64 + protected function loadOAuthAccountData() { 65 + $uri = new PhutilURI('https://api.amazon.com/user/profile'); 66 + $uri->replaceQueryParam('access_token', $this->getAccessToken()); 67 + 68 + $future = new HTTPSFuture($uri); 69 + list($body) = $future->resolvex(); 70 + 71 + try { 72 + return phutil_json_decode($body); 73 + } catch (PhutilJSONParserException $ex) { 74 + throw new PhutilProxyException( 75 + pht('Expected valid JSON response from Amazon account data request.'), 76 + $ex); 77 + } 78 + } 79 + 80 + }
+86
src/applications/auth/adapter/PhutilAsanaAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Asana OAuth2. 5 + */ 6 + final class PhutilAsanaAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'asana'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'asana.com'; 14 + } 15 + 16 + public function getAccountID() { 17 + return $this->getOAuthAccountData('id'); 18 + } 19 + 20 + public function getAccountEmail() { 21 + return $this->getOAuthAccountData('email'); 22 + } 23 + 24 + public function getAccountName() { 25 + return null; 26 + } 27 + 28 + public function getAccountImageURI() { 29 + $photo = $this->getOAuthAccountData('photo', array()); 30 + if (is_array($photo)) { 31 + return idx($photo, 'image_128x128'); 32 + } else { 33 + return null; 34 + } 35 + } 36 + 37 + public function getAccountURI() { 38 + return null; 39 + } 40 + 41 + public function getAccountRealName() { 42 + return $this->getOAuthAccountData('name'); 43 + } 44 + 45 + protected function getAuthenticateBaseURI() { 46 + return 'https://app.asana.com/-/oauth_authorize'; 47 + } 48 + 49 + protected function getTokenBaseURI() { 50 + return 'https://app.asana.com/-/oauth_token'; 51 + } 52 + 53 + public function getScope() { 54 + return null; 55 + } 56 + 57 + public function getExtraAuthenticateParameters() { 58 + return array( 59 + 'response_type' => 'code', 60 + ); 61 + } 62 + 63 + public function getExtraTokenParameters() { 64 + return array( 65 + 'grant_type' => 'authorization_code', 66 + ); 67 + } 68 + 69 + public function getExtraRefreshParameters() { 70 + return array( 71 + 'grant_type' => 'refresh_token', 72 + ); 73 + } 74 + 75 + public function supportsTokenRefresh() { 76 + return true; 77 + } 78 + 79 + protected function loadOAuthAccountData() { 80 + return id(new PhutilAsanaFuture()) 81 + ->setAccessToken($this->getAccessToken()) 82 + ->setRawAsanaQuery('users/me') 83 + ->resolve(); 84 + } 85 + 86 + }
+123
src/applications/auth/adapter/PhutilAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Abstract interface to an identity provider or authentication source, like 5 + * Twitter, Facebook or Google. 6 + * 7 + * Generally, adapters are handed some set of credentials particular to the 8 + * provider they adapt, and they turn those credentials into standard 9 + * information about the user's identity. For example, the LDAP adapter is given 10 + * a username and password (and some other configuration information), uses them 11 + * to talk to the LDAP server, and produces a username, email, and so forth. 12 + * 13 + * Since the credentials a provider requires are specific to each provider, the 14 + * base adapter does not specify how an adapter should be constructed or 15 + * configured -- only what information it is expected to be able to provide once 16 + * properly configured. 17 + */ 18 + abstract class PhutilAuthAdapter extends Phobject { 19 + 20 + /** 21 + * Get a unique identifier associated with the identity. For most providers, 22 + * this is an account ID. 23 + * 24 + * The account ID needs to be unique within this adapter's configuration, such 25 + * that `<adapterKey, accountID>` is globally unique and always identifies the 26 + * same identity. 27 + * 28 + * If the adapter was unable to authenticate an identity, it should return 29 + * `null`. 30 + * 31 + * @return string|null Unique account identifier, or `null` if authentication 32 + * failed. 33 + */ 34 + abstract public function getAccountID(); 35 + 36 + 37 + /** 38 + * Get a string identifying this adapter, like "ldap". This string should be 39 + * unique to the adapter class. 40 + * 41 + * @return string Unique adapter identifier. 42 + */ 43 + abstract public function getAdapterType(); 44 + 45 + 46 + /** 47 + * Get a string identifying the domain this adapter is acting on. This allows 48 + * an adapter (like LDAP) to act against different identity domains without 49 + * conflating credentials. For providers like Facebook or Google, the adapters 50 + * just return the relevant domain name. 51 + * 52 + * @return string Domain the adapter is associated with. 53 + */ 54 + abstract public function getAdapterDomain(); 55 + 56 + 57 + /** 58 + * Generate a string uniquely identifying this adapter configuration. Within 59 + * the scope of a given key, all account IDs must uniquely identify exactly 60 + * one identity. 61 + * 62 + * @return string Unique identifier for this adapter configuration. 63 + */ 64 + public function getAdapterKey() { 65 + return $this->getAdapterType().':'.$this->getAdapterDomain(); 66 + } 67 + 68 + 69 + /** 70 + * Optionally, return an email address associated with this account. 71 + * 72 + * @return string|null An email address associated with the account, or 73 + * `null` if data is not available. 74 + */ 75 + public function getAccountEmail() { 76 + return null; 77 + } 78 + 79 + 80 + /** 81 + * Optionally, return a human readable username associated with this account. 82 + * 83 + * @return string|null Account username, or `null` if data isn't available. 84 + */ 85 + public function getAccountName() { 86 + return null; 87 + } 88 + 89 + 90 + /** 91 + * Optionally, return a URI corresponding to a human-viewable profile for 92 + * this account. 93 + * 94 + * @return string|null A profile URI associated with this account, or 95 + * `null` if the data isn't available. 96 + */ 97 + public function getAccountURI() { 98 + return null; 99 + } 100 + 101 + 102 + /** 103 + * Optionally, return a profile image URI associated with this account. 104 + * 105 + * @return string|null URI for an account profile image, or `null` if one is 106 + * not available. 107 + */ 108 + public function getAccountImageURI() { 109 + return null; 110 + } 111 + 112 + 113 + /** 114 + * Optionally, return a real name associated with this account. 115 + * 116 + * @return string|null A human real name, or `null` if this data is not 117 + * available. 118 + */ 119 + public function getAccountRealName() { 120 + return null; 121 + } 122 + 123 + }
+73
src/applications/auth/adapter/PhutilBitbucketAuthAdapter.php
··· 1 + <?php 2 + 3 + final class PhutilBitbucketAuthAdapter extends PhutilOAuth1AuthAdapter { 4 + 5 + private $userInfo; 6 + 7 + public function getAccountID() { 8 + return idx($this->getUserInfo(), 'username'); 9 + } 10 + 11 + public function getAccountName() { 12 + return idx($this->getUserInfo(), 'display_name'); 13 + } 14 + 15 + public function getAccountURI() { 16 + $name = $this->getAccountID(); 17 + if (strlen($name)) { 18 + return 'https://bitbucket.org/'.$name; 19 + } 20 + return null; 21 + } 22 + 23 + public function getAccountImageURI() { 24 + return idx($this->getUserInfo(), 'avatar'); 25 + } 26 + 27 + public function getAccountRealName() { 28 + $parts = array( 29 + idx($this->getUserInfo(), 'first_name'), 30 + idx($this->getUserInfo(), 'last_name'), 31 + ); 32 + $parts = array_filter($parts); 33 + return implode(' ', $parts); 34 + } 35 + 36 + public function getAdapterType() { 37 + return 'bitbucket'; 38 + } 39 + 40 + public function getAdapterDomain() { 41 + return 'bitbucket.org'; 42 + } 43 + 44 + protected function getRequestTokenURI() { 45 + return 'https://bitbucket.org/api/1.0/oauth/request_token'; 46 + } 47 + 48 + protected function getAuthorizeTokenURI() { 49 + return 'https://bitbucket.org/api/1.0/oauth/authenticate'; 50 + } 51 + 52 + protected function getValidateTokenURI() { 53 + return 'https://bitbucket.org/api/1.0/oauth/access_token'; 54 + } 55 + 56 + private function getUserInfo() { 57 + if ($this->userInfo === null) { 58 + // We don't need any of the data in the handshake, but do need to 59 + // finish the process. This makes sure we've completed the handshake. 60 + $this->getHandshakeData(); 61 + 62 + $uri = new PhutilURI('https://bitbucket.org/api/1.0/user'); 63 + 64 + $data = $this->newOAuth1Future($uri) 65 + ->setMethod('GET') 66 + ->resolveJSON(); 67 + 68 + $this->userInfo = idx($data, 'user', array()); 69 + } 70 + return $this->userInfo; 71 + } 72 + 73 + }
+84
src/applications/auth/adapter/PhutilDisqusAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Disqus OAuth2. 5 + */ 6 + final class PhutilDisqusAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'disqus'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'disqus.com'; 14 + } 15 + 16 + public function getAccountID() { 17 + return $this->getOAuthAccountData('id'); 18 + } 19 + 20 + public function getAccountEmail() { 21 + return $this->getOAuthAccountData('email'); 22 + } 23 + 24 + public function getAccountName() { 25 + return $this->getOAuthAccountData('username'); 26 + } 27 + 28 + public function getAccountImageURI() { 29 + return $this->getOAuthAccountData('avatar', 'permalink'); 30 + } 31 + 32 + public function getAccountURI() { 33 + return $this->getOAuthAccountData('profileUrl'); 34 + } 35 + 36 + public function getAccountRealName() { 37 + return $this->getOAuthAccountData('name'); 38 + } 39 + 40 + protected function getAuthenticateBaseURI() { 41 + return 'https://disqus.com/api/oauth/2.0/authorize/'; 42 + } 43 + 44 + protected function getTokenBaseURI() { 45 + return 'https://disqus.com/api/oauth/2.0/access_token/'; 46 + } 47 + 48 + public function getScope() { 49 + return 'read'; 50 + } 51 + 52 + public function getExtraAuthenticateParameters() { 53 + return array( 54 + 'response_type' => 'code', 55 + ); 56 + } 57 + 58 + public function getExtraTokenParameters() { 59 + return array( 60 + 'grant_type' => 'authorization_code', 61 + ); 62 + } 63 + 64 + protected function loadOAuthAccountData() { 65 + $uri = new PhutilURI('https://disqus.com/api/3.0/users/details.json'); 66 + $uri->replaceQueryParam('api_key', $this->getClientID()); 67 + $uri->replaceQueryParam('access_token', $this->getAccessToken()); 68 + $uri = (string)$uri; 69 + 70 + $future = new HTTPSFuture($uri); 71 + $future->setMethod('GET'); 72 + list($body) = $future->resolvex(); 73 + 74 + try { 75 + $data = phutil_json_decode($body); 76 + return $data['response']; 77 + } catch (PhutilJSONParserException $ex) { 78 + throw new PhutilProxyException( 79 + pht('Expected valid JSON response from Disqus account data request.'), 80 + $ex); 81 + } 82 + } 83 + 84 + }
+42
src/applications/auth/adapter/PhutilEmptyAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Empty authentication adapter with no logic. 5 + * 6 + * This adapter can be used when you need an adapter for some technical reason 7 + * but it doesn't make sense to put logic inside it. 8 + */ 9 + final class PhutilEmptyAuthAdapter extends PhutilAuthAdapter { 10 + 11 + private $accountID; 12 + private $adapterType; 13 + private $adapterDomain; 14 + 15 + public function setAdapterDomain($adapter_domain) { 16 + $this->adapterDomain = $adapter_domain; 17 + return $this; 18 + } 19 + 20 + public function getAdapterDomain() { 21 + return $this->adapterDomain; 22 + } 23 + 24 + public function setAdapterType($adapter_type) { 25 + $this->adapterType = $adapter_type; 26 + return $this; 27 + } 28 + 29 + public function getAdapterType() { 30 + return $this->adapterType; 31 + } 32 + 33 + public function setAccountID($account_id) { 34 + $this->accountID = $account_id; 35 + return $this; 36 + } 37 + 38 + public function getAccountID() { 39 + return $this->accountID; 40 + } 41 + 42 + }
+114
src/applications/auth/adapter/PhutilFacebookAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Facebook OAuth2. 5 + */ 6 + final class PhutilFacebookAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + private $requireSecureBrowsing; 9 + 10 + public function setRequireSecureBrowsing($require_secure_browsing) { 11 + $this->requireSecureBrowsing = $require_secure_browsing; 12 + return $this; 13 + } 14 + 15 + public function getAdapterType() { 16 + return 'facebook'; 17 + } 18 + 19 + public function getAdapterDomain() { 20 + return 'facebook.com'; 21 + } 22 + 23 + public function getAccountID() { 24 + return $this->getOAuthAccountData('id'); 25 + } 26 + 27 + public function getAccountEmail() { 28 + return $this->getOAuthAccountData('email'); 29 + } 30 + 31 + public function getAccountName() { 32 + $link = $this->getOAuthAccountData('link'); 33 + if (!$link) { 34 + return null; 35 + } 36 + 37 + $matches = null; 38 + if (!preg_match('@/([^/]+)$@', $link, $matches)) { 39 + return null; 40 + } 41 + 42 + return $matches[1]; 43 + } 44 + 45 + public function getAccountImageURI() { 46 + $picture = $this->getOAuthAccountData('picture'); 47 + if ($picture) { 48 + $picture_data = idx($picture, 'data'); 49 + if ($picture_data) { 50 + return idx($picture_data, 'url'); 51 + } 52 + } 53 + return null; 54 + } 55 + 56 + public function getAccountURI() { 57 + return $this->getOAuthAccountData('link'); 58 + } 59 + 60 + public function getAccountRealName() { 61 + return $this->getOAuthAccountData('name'); 62 + } 63 + 64 + public function getAccountSecuritySettings() { 65 + return $this->getOAuthAccountData('security_settings'); 66 + } 67 + 68 + protected function getAuthenticateBaseURI() { 69 + return 'https://www.facebook.com/dialog/oauth'; 70 + } 71 + 72 + protected function getTokenBaseURI() { 73 + return 'https://graph.facebook.com/oauth/access_token'; 74 + } 75 + 76 + protected function loadOAuthAccountData() { 77 + $fields = array( 78 + 'id', 79 + 'name', 80 + 'email', 81 + 'link', 82 + 'security_settings', 83 + 'picture', 84 + ); 85 + 86 + $uri = new PhutilURI('https://graph.facebook.com/me'); 87 + $uri->replaceQueryParam('access_token', $this->getAccessToken()); 88 + $uri->replaceQueryParam('fields', implode(',', $fields)); 89 + list($body) = id(new HTTPSFuture($uri))->resolvex(); 90 + 91 + $data = null; 92 + try { 93 + $data = phutil_json_decode($body); 94 + } catch (PhutilJSONParserException $ex) { 95 + throw new PhutilProxyException( 96 + pht('Expected valid JSON response from Facebook account data request.'), 97 + $ex); 98 + } 99 + 100 + if ($this->requireSecureBrowsing) { 101 + if (empty($data['security_settings']['secure_browsing']['enabled'])) { 102 + throw new Exception( 103 + pht( 104 + 'This Phabricator install requires you to enable Secure Browsing '. 105 + 'on your Facebook account in order to use it to log in to '. 106 + 'Phabricator. For more information, see %s', 107 + 'https://www.facebook.com/help/156201551113407/')); 108 + } 109 + } 110 + 111 + return $data; 112 + } 113 + 114 + }
+72
src/applications/auth/adapter/PhutilGitHubAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Github OAuth2. 5 + */ 6 + final class PhutilGitHubAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'github'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'github.com'; 14 + } 15 + 16 + public function getAccountID() { 17 + return $this->getOAuthAccountData('id'); 18 + } 19 + 20 + public function getAccountEmail() { 21 + return $this->getOAuthAccountData('email'); 22 + } 23 + 24 + public function getAccountName() { 25 + return $this->getOAuthAccountData('login'); 26 + } 27 + 28 + public function getAccountImageURI() { 29 + return $this->getOAuthAccountData('avatar_url'); 30 + } 31 + 32 + public function getAccountURI() { 33 + $name = $this->getAccountName(); 34 + if (strlen($name)) { 35 + return 'https://github.com/'.$name; 36 + } 37 + return null; 38 + } 39 + 40 + public function getAccountRealName() { 41 + return $this->getOAuthAccountData('name'); 42 + } 43 + 44 + protected function getAuthenticateBaseURI() { 45 + return 'https://github.com/login/oauth/authorize'; 46 + } 47 + 48 + protected function getTokenBaseURI() { 49 + return 'https://github.com/login/oauth/access_token'; 50 + } 51 + 52 + protected function loadOAuthAccountData() { 53 + $uri = new PhutilURI('https://api.github.com/user'); 54 + $uri->replaceQueryParam('access_token', $this->getAccessToken()); 55 + 56 + $future = new HTTPSFuture($uri); 57 + 58 + // NOTE: GitHub requires a User-Agent string. 59 + $future->addHeader('User-Agent', __CLASS__); 60 + 61 + list($body) = $future->resolvex(); 62 + 63 + try { 64 + return phutil_json_decode($body); 65 + } catch (PhutilJSONParserException $ex) { 66 + throw new PhutilProxyException( 67 + pht('Expected valid JSON response from GitHub account data request.'), 68 + $ex); 69 + } 70 + } 71 + 72 + }
+105
src/applications/auth/adapter/PhutilGoogleAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Google OAuth2. 5 + */ 6 + final class PhutilGoogleAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'google'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'google.com'; 14 + } 15 + 16 + public function getAccountID() { 17 + return $this->getAccountEmail(); 18 + } 19 + 20 + public function getAccountEmail() { 21 + return $this->getOAuthAccountData('email'); 22 + } 23 + 24 + public function getAccountName() { 25 + // Guess account name from email address, this is just a hint anyway. 26 + $email = $this->getAccountEmail(); 27 + $email = explode('@', $email); 28 + $email = head($email); 29 + return $email; 30 + } 31 + 32 + public function getAccountImageURI() { 33 + $uri = $this->getOAuthAccountData('picture'); 34 + 35 + // Change the "sz" parameter ("size") from the default to 100 to ask for 36 + // a 100x100px image. 37 + if ($uri !== null) { 38 + $uri = new PhutilURI($uri); 39 + $uri->replaceQueryParam('sz', 100); 40 + $uri = (string)$uri; 41 + } 42 + 43 + return $uri; 44 + } 45 + 46 + public function getAccountURI() { 47 + return $this->getOAuthAccountData('link'); 48 + } 49 + 50 + public function getAccountRealName() { 51 + return $this->getOAuthAccountData('name'); 52 + } 53 + 54 + protected function getAuthenticateBaseURI() { 55 + return 'https://accounts.google.com/o/oauth2/auth'; 56 + } 57 + 58 + protected function getTokenBaseURI() { 59 + return 'https://accounts.google.com/o/oauth2/token'; 60 + } 61 + 62 + public function getScope() { 63 + $scopes = array( 64 + 'email', 65 + 'profile', 66 + ); 67 + 68 + return implode(' ', $scopes); 69 + } 70 + 71 + public function getExtraAuthenticateParameters() { 72 + return array( 73 + 'response_type' => 'code', 74 + ); 75 + } 76 + 77 + public function getExtraTokenParameters() { 78 + return array( 79 + 'grant_type' => 'authorization_code', 80 + ); 81 + } 82 + 83 + protected function loadOAuthAccountData() { 84 + $uri = new PhutilURI('https://www.googleapis.com/userinfo/v2/me'); 85 + $uri->replaceQueryParam('access_token', $this->getAccessToken()); 86 + 87 + $future = new HTTPSFuture($uri); 88 + list($status, $body) = $future->resolve(); 89 + 90 + if ($status->isError()) { 91 + throw $status; 92 + } 93 + 94 + try { 95 + $result = phutil_json_decode($body); 96 + } catch (PhutilJSONParserException $ex) { 97 + throw new PhutilProxyException( 98 + pht('Expected valid JSON response from Google account data request.'), 99 + $ex); 100 + } 101 + 102 + return $result; 103 + } 104 + 105 + }
+164
src/applications/auth/adapter/PhutilJIRAAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for JIRA OAuth1. 5 + */ 6 + final class PhutilJIRAAuthAdapter extends PhutilOAuth1AuthAdapter { 7 + 8 + // TODO: JIRA tokens expire (after 5 years) and we could surface and store 9 + // that. 10 + 11 + private $jiraBaseURI; 12 + private $adapterDomain; 13 + private $currentSession; 14 + private $userInfo; 15 + 16 + public function setJIRABaseURI($jira_base_uri) { 17 + $this->jiraBaseURI = $jira_base_uri; 18 + return $this; 19 + } 20 + 21 + public function getJIRABaseURI() { 22 + return $this->jiraBaseURI; 23 + } 24 + 25 + public function getAccountID() { 26 + // Make sure the handshake is finished; this method is used for its 27 + // side effect by Auth providers. 28 + $this->getHandshakeData(); 29 + 30 + return idx($this->getUserInfo(), 'key'); 31 + } 32 + 33 + public function getAccountName() { 34 + return idx($this->getUserInfo(), 'name'); 35 + } 36 + 37 + public function getAccountImageURI() { 38 + $avatars = idx($this->getUserInfo(), 'avatarUrls'); 39 + if ($avatars) { 40 + return idx($avatars, '48x48'); 41 + } 42 + return null; 43 + } 44 + 45 + public function getAccountRealName() { 46 + return idx($this->getUserInfo(), 'displayName'); 47 + } 48 + 49 + public function getAccountEmail() { 50 + return idx($this->getUserInfo(), 'emailAddress'); 51 + } 52 + 53 + public function getAdapterType() { 54 + return 'jira'; 55 + } 56 + 57 + public function getAdapterDomain() { 58 + return $this->adapterDomain; 59 + } 60 + 61 + public function setAdapterDomain($domain) { 62 + $this->adapterDomain = $domain; 63 + return $this; 64 + } 65 + 66 + protected function getSignatureMethod() { 67 + return 'RSA-SHA1'; 68 + } 69 + 70 + protected function getRequestTokenURI() { 71 + return $this->getJIRAURI('plugins/servlet/oauth/request-token'); 72 + } 73 + 74 + protected function getAuthorizeTokenURI() { 75 + return $this->getJIRAURI('plugins/servlet/oauth/authorize'); 76 + } 77 + 78 + protected function getValidateTokenURI() { 79 + return $this->getJIRAURI('plugins/servlet/oauth/access-token'); 80 + } 81 + 82 + private function getJIRAURI($path) { 83 + return rtrim($this->jiraBaseURI, '/').'/'.ltrim($path, '/'); 84 + } 85 + 86 + private function getUserInfo() { 87 + if ($this->userInfo === null) { 88 + $this->currentSession = $this->newJIRAFuture('rest/auth/1/session', 'GET') 89 + ->resolveJSON(); 90 + 91 + // The session call gives us the username, but not the user key or other 92 + // information. Make a second call to get additional information. 93 + 94 + $params = array( 95 + 'username' => $this->currentSession['name'], 96 + ); 97 + 98 + $this->userInfo = $this->newJIRAFuture('rest/api/2/user', 'GET', $params) 99 + ->resolveJSON(); 100 + } 101 + 102 + return $this->userInfo; 103 + } 104 + 105 + public static function newJIRAKeypair() { 106 + $config = array( 107 + 'digest_alg' => 'sha512', 108 + 'private_key_bits' => 4096, 109 + 'private_key_type' => OPENSSL_KEYTYPE_RSA, 110 + ); 111 + 112 + $res = openssl_pkey_new($config); 113 + if (!$res) { 114 + throw new Exception(pht('%s failed!', 'openssl_pkey_new()')); 115 + } 116 + 117 + $private_key = null; 118 + $ok = openssl_pkey_export($res, $private_key); 119 + if (!$ok) { 120 + throw new Exception(pht('%s failed!', 'openssl_pkey_export()')); 121 + } 122 + 123 + $public_key = openssl_pkey_get_details($res); 124 + if (!$ok || empty($public_key['key'])) { 125 + throw new Exception(pht('%s failed!', 'openssl_pkey_get_details()')); 126 + } 127 + $public_key = $public_key['key']; 128 + 129 + return array($public_key, $private_key); 130 + } 131 + 132 + 133 + /** 134 + * JIRA indicates that the user has clicked the "Deny" button by passing a 135 + * well known `oauth_verifier` value ("denied"), which we check for here. 136 + */ 137 + protected function willFinishOAuthHandshake() { 138 + $jira_magic_word = 'denied'; 139 + if ($this->getVerifier() == $jira_magic_word) { 140 + throw new PhutilAuthUserAbortedException(); 141 + } 142 + } 143 + 144 + public function newJIRAFuture($path, $method, $params = array()) { 145 + if ($method == 'GET') { 146 + $uri_params = $params; 147 + $body_params = array(); 148 + } else { 149 + // For other types of requests, JIRA expects the request body to be 150 + // JSON encoded. 151 + $uri_params = array(); 152 + $body_params = phutil_json_encode($params); 153 + } 154 + 155 + $uri = new PhutilURI($this->getJIRAURI($path), $uri_params); 156 + 157 + // JIRA returns a 415 error if we don't provide a Content-Type header. 158 + 159 + return $this->newOAuth1Future($uri, $body_params) 160 + ->setMethod($method) 161 + ->addHeader('Content-Type', 'application/json'); 162 + } 163 + 164 + }
+505
src/applications/auth/adapter/PhutilLDAPAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Retrieve identify information from LDAP accounts. 5 + */ 6 + final class PhutilLDAPAuthAdapter extends PhutilAuthAdapter { 7 + 8 + private $hostname; 9 + private $port = 389; 10 + 11 + private $baseDistinguishedName; 12 + private $searchAttributes = array(); 13 + private $usernameAttribute; 14 + private $realNameAttributes = array(); 15 + private $ldapVersion = 3; 16 + private $ldapReferrals; 17 + private $ldapStartTLS; 18 + private $anonymousUsername; 19 + private $anonymousPassword; 20 + private $activeDirectoryDomain; 21 + private $alwaysSearch; 22 + 23 + private $loginUsername; 24 + private $loginPassword; 25 + 26 + private $ldapUserData; 27 + private $ldapConnection; 28 + 29 + public function getAdapterType() { 30 + return 'ldap'; 31 + } 32 + 33 + public function setHostname($host) { 34 + $this->hostname = $host; 35 + return $this; 36 + } 37 + 38 + public function setPort($port) { 39 + $this->port = $port; 40 + return $this; 41 + } 42 + 43 + public function getAdapterDomain() { 44 + return 'self'; 45 + } 46 + 47 + public function setBaseDistinguishedName($base_distinguished_name) { 48 + $this->baseDistinguishedName = $base_distinguished_name; 49 + return $this; 50 + } 51 + 52 + public function setSearchAttributes(array $search_attributes) { 53 + $this->searchAttributes = $search_attributes; 54 + return $this; 55 + } 56 + 57 + public function setUsernameAttribute($username_attribute) { 58 + $this->usernameAttribute = $username_attribute; 59 + return $this; 60 + } 61 + 62 + public function setRealNameAttributes(array $attributes) { 63 + $this->realNameAttributes = $attributes; 64 + return $this; 65 + } 66 + 67 + public function setLDAPVersion($ldap_version) { 68 + $this->ldapVersion = $ldap_version; 69 + return $this; 70 + } 71 + 72 + public function setLDAPReferrals($ldap_referrals) { 73 + $this->ldapReferrals = $ldap_referrals; 74 + return $this; 75 + } 76 + 77 + public function setLDAPStartTLS($ldap_start_tls) { 78 + $this->ldapStartTLS = $ldap_start_tls; 79 + return $this; 80 + } 81 + 82 + public function setAnonymousUsername($anonymous_username) { 83 + $this->anonymousUsername = $anonymous_username; 84 + return $this; 85 + } 86 + 87 + public function setAnonymousPassword( 88 + PhutilOpaqueEnvelope $anonymous_password) { 89 + $this->anonymousPassword = $anonymous_password; 90 + return $this; 91 + } 92 + 93 + public function setLoginUsername($login_username) { 94 + $this->loginUsername = $login_username; 95 + return $this; 96 + } 97 + 98 + public function setLoginPassword(PhutilOpaqueEnvelope $login_password) { 99 + $this->loginPassword = $login_password; 100 + return $this; 101 + } 102 + 103 + public function setActiveDirectoryDomain($domain) { 104 + $this->activeDirectoryDomain = $domain; 105 + return $this; 106 + } 107 + 108 + public function setAlwaysSearch($always_search) { 109 + $this->alwaysSearch = $always_search; 110 + return $this; 111 + } 112 + 113 + public function getAccountID() { 114 + return $this->readLDAPRecordAccountID($this->getLDAPUserData()); 115 + } 116 + 117 + public function getAccountName() { 118 + return $this->readLDAPRecordAccountName($this->getLDAPUserData()); 119 + } 120 + 121 + public function getAccountRealName() { 122 + return $this->readLDAPRecordRealName($this->getLDAPUserData()); 123 + } 124 + 125 + public function getAccountEmail() { 126 + return $this->readLDAPRecordEmail($this->getLDAPUserData()); 127 + } 128 + 129 + public function readLDAPRecordAccountID(array $record) { 130 + $key = $this->usernameAttribute; 131 + if (!strlen($key)) { 132 + $key = head($this->searchAttributes); 133 + } 134 + return $this->readLDAPData($record, $key); 135 + } 136 + 137 + public function readLDAPRecordAccountName(array $record) { 138 + return $this->readLDAPRecordAccountID($record); 139 + } 140 + 141 + public function readLDAPRecordRealName(array $record) { 142 + $parts = array(); 143 + foreach ($this->realNameAttributes as $attribute) { 144 + $parts[] = $this->readLDAPData($record, $attribute); 145 + } 146 + $parts = array_filter($parts); 147 + 148 + if ($parts) { 149 + return implode(' ', $parts); 150 + } 151 + 152 + return null; 153 + } 154 + 155 + public function readLDAPRecordEmail(array $record) { 156 + return $this->readLDAPData($record, 'mail'); 157 + } 158 + 159 + private function getLDAPUserData() { 160 + if ($this->ldapUserData === null) { 161 + $this->ldapUserData = $this->loadLDAPUserData(); 162 + } 163 + 164 + return $this->ldapUserData; 165 + } 166 + 167 + private function readLDAPData(array $data, $key, $default = null) { 168 + $list = idx($data, $key); 169 + if ($list === null) { 170 + // At least in some cases (and maybe in all cases) the results from 171 + // ldap_search() are keyed in lowercase. If we missed on the first 172 + // try, retry with a lowercase key. 173 + $list = idx($data, phutil_utf8_strtolower($key)); 174 + } 175 + 176 + // NOTE: In most cases, the property is an array, like: 177 + // 178 + // array( 179 + // 'count' => 1, 180 + // 0 => 'actual-value-we-want', 181 + // ) 182 + // 183 + // However, in at least the case of 'dn', the property is a bare string. 184 + 185 + if (is_scalar($list) && strlen($list)) { 186 + return $list; 187 + } else if (is_array($list)) { 188 + return $list[0]; 189 + } else { 190 + return $default; 191 + } 192 + } 193 + 194 + private function formatLDAPAttributeSearch($attribute, $login_user) { 195 + // If the attribute contains the literal token "${login}", treat it as a 196 + // query and substitute the user's login name for the token. 197 + 198 + if (strpos($attribute, '${login}') !== false) { 199 + $escaped_user = ldap_sprintf('%S', $login_user); 200 + $attribute = str_replace('${login}', $escaped_user, $attribute); 201 + return $attribute; 202 + } 203 + 204 + // Otherwise, treat it as a simple attribute search. 205 + 206 + return ldap_sprintf( 207 + '%Q=%S', 208 + $attribute, 209 + $login_user); 210 + } 211 + 212 + private function loadLDAPUserData() { 213 + $conn = $this->establishConnection(); 214 + 215 + $login_user = $this->loginUsername; 216 + $login_pass = $this->loginPassword; 217 + 218 + if ($this->shouldBindWithoutIdentity()) { 219 + $distinguished_name = null; 220 + $search_query = null; 221 + foreach ($this->searchAttributes as $attribute) { 222 + $search_query = $this->formatLDAPAttributeSearch( 223 + $attribute, 224 + $login_user); 225 + $record = $this->searchLDAPForRecord($search_query); 226 + if ($record) { 227 + $distinguished_name = $this->readLDAPData($record, 'dn'); 228 + break; 229 + } 230 + } 231 + if ($distinguished_name === null) { 232 + throw new PhutilAuthCredentialException(); 233 + } 234 + } else { 235 + $search_query = $this->formatLDAPAttributeSearch( 236 + head($this->searchAttributes), 237 + $login_user); 238 + if ($this->activeDirectoryDomain) { 239 + $distinguished_name = ldap_sprintf( 240 + '%s@%Q', 241 + $login_user, 242 + $this->activeDirectoryDomain); 243 + } else { 244 + $distinguished_name = ldap_sprintf( 245 + '%Q,%Q', 246 + $search_query, 247 + $this->baseDistinguishedName); 248 + } 249 + } 250 + 251 + $this->bindLDAP($conn, $distinguished_name, $login_pass); 252 + 253 + $result = $this->searchLDAPForRecord($search_query); 254 + if (!$result) { 255 + // This is unusual (since the bind succeeded) but we've seen it at least 256 + // once in the wild, where the anonymous user is allowed to search but 257 + // the credentialed user is not. 258 + 259 + // If we don't have anonymous credentials, raise an explicit exception 260 + // here since we'll fail a typehint if we don't return an array anyway 261 + // and this is a more useful error. 262 + 263 + // If we do have anonymous credentials, we'll rebind and try the search 264 + // again below. Doing this automatically means things work correctly more 265 + // often without requiring additional configuration. 266 + if (!$this->shouldBindWithoutIdentity()) { 267 + // No anonymous credentials, so we just fail here. 268 + throw new Exception( 269 + pht( 270 + 'LDAP: Failed to retrieve record for user "%s" when searching. '. 271 + 'Credentialed users may not be able to search your LDAP server. '. 272 + 'Try configuring anonymous credentials or fully anonymous binds.', 273 + $login_user)); 274 + } else { 275 + // Rebind as anonymous and try the search again. 276 + $user = $this->anonymousUsername; 277 + $pass = $this->anonymousPassword; 278 + $this->bindLDAP($conn, $user, $pass); 279 + 280 + $result = $this->searchLDAPForRecord($search_query); 281 + if (!$result) { 282 + throw new Exception( 283 + pht( 284 + 'LDAP: Failed to retrieve record for user "%s" when searching '. 285 + 'with both user and anonymous credentials.', 286 + $login_user)); 287 + } 288 + } 289 + } 290 + 291 + return $result; 292 + } 293 + 294 + private function establishConnection() { 295 + if (!$this->ldapConnection) { 296 + $host = $this->hostname; 297 + $port = $this->port; 298 + 299 + $profiler = PhutilServiceProfiler::getInstance(); 300 + $call_id = $profiler->beginServiceCall( 301 + array( 302 + 'type' => 'ldap', 303 + 'call' => 'connect', 304 + 'host' => $host, 305 + 'port' => $this->port, 306 + )); 307 + 308 + $conn = @ldap_connect($host, $this->port); 309 + 310 + $profiler->endServiceCall( 311 + $call_id, 312 + array( 313 + 'ok' => (bool)$conn, 314 + )); 315 + 316 + if (!$conn) { 317 + throw new Exception( 318 + pht('Unable to connect to LDAP server (%s:%d).', $host, $port)); 319 + } 320 + 321 + $options = array( 322 + LDAP_OPT_PROTOCOL_VERSION => (int)$this->ldapVersion, 323 + LDAP_OPT_REFERRALS => (int)$this->ldapReferrals, 324 + ); 325 + 326 + foreach ($options as $name => $value) { 327 + $ok = @ldap_set_option($conn, $name, $value); 328 + if (!$ok) { 329 + $this->raiseConnectionException( 330 + $conn, 331 + pht( 332 + "Unable to set LDAP option '%s' to value '%s'!", 333 + $name, 334 + $value)); 335 + } 336 + } 337 + 338 + if ($this->ldapStartTLS) { 339 + $profiler = PhutilServiceProfiler::getInstance(); 340 + $call_id = $profiler->beginServiceCall( 341 + array( 342 + 'type' => 'ldap', 343 + 'call' => 'start-tls', 344 + )); 345 + 346 + // NOTE: This boils down to a function call to ldap_start_tls_s() in 347 + // C, which is a service call. 348 + $ok = @ldap_start_tls($conn); 349 + 350 + $profiler->endServiceCall( 351 + $call_id, 352 + array()); 353 + 354 + if (!$ok) { 355 + $this->raiseConnectionException( 356 + $conn, 357 + pht('Unable to start TLS connection when connecting to LDAP.')); 358 + } 359 + } 360 + 361 + if ($this->shouldBindWithoutIdentity()) { 362 + $user = $this->anonymousUsername; 363 + $pass = $this->anonymousPassword; 364 + $this->bindLDAP($conn, $user, $pass); 365 + } 366 + 367 + $this->ldapConnection = $conn; 368 + } 369 + 370 + return $this->ldapConnection; 371 + } 372 + 373 + 374 + private function searchLDAPForRecord($dn) { 375 + $conn = $this->establishConnection(); 376 + 377 + $results = $this->searchLDAP('%Q', $dn); 378 + 379 + if (!$results) { 380 + return null; 381 + } 382 + 383 + if (count($results) > 1) { 384 + throw new Exception( 385 + pht( 386 + 'LDAP record query returned more than one result. The query must '. 387 + 'uniquely identify a record.')); 388 + } 389 + 390 + return head($results); 391 + } 392 + 393 + public function searchLDAP($pattern /* ... */) { 394 + $args = func_get_args(); 395 + $query = call_user_func_array('ldap_sprintf', $args); 396 + 397 + $conn = $this->establishConnection(); 398 + 399 + $profiler = PhutilServiceProfiler::getInstance(); 400 + $call_id = $profiler->beginServiceCall( 401 + array( 402 + 'type' => 'ldap', 403 + 'call' => 'search', 404 + 'dn' => $this->baseDistinguishedName, 405 + 'query' => $query, 406 + )); 407 + 408 + $result = @ldap_search($conn, $this->baseDistinguishedName, $query); 409 + 410 + $profiler->endServiceCall($call_id, array()); 411 + 412 + if (!$result) { 413 + $this->raiseConnectionException( 414 + $conn, 415 + pht('LDAP search failed.')); 416 + } 417 + 418 + $entries = @ldap_get_entries($conn, $result); 419 + 420 + if (!$entries) { 421 + $this->raiseConnectionException( 422 + $conn, 423 + pht('Failed to get LDAP entries from search result.')); 424 + } 425 + 426 + $results = array(); 427 + for ($ii = 0; $ii < $entries['count']; $ii++) { 428 + $results[] = $entries[$ii]; 429 + } 430 + 431 + return $results; 432 + } 433 + 434 + private function raiseConnectionException($conn, $message) { 435 + $errno = @ldap_errno($conn); 436 + $error = @ldap_error($conn); 437 + 438 + // This is `LDAP_INVALID_CREDENTIALS`. 439 + if ($errno == 49) { 440 + throw new PhutilAuthCredentialException(); 441 + } 442 + 443 + if ($errno || $error) { 444 + $full_message = pht( 445 + "LDAP Exception: %s\nLDAP Error #%d: %s", 446 + $message, 447 + $errno, 448 + $error); 449 + } else { 450 + $full_message = pht( 451 + 'LDAP Exception: %s', 452 + $message); 453 + } 454 + 455 + throw new Exception($full_message); 456 + } 457 + 458 + private function bindLDAP($conn, $user, PhutilOpaqueEnvelope $pass) { 459 + $profiler = PhutilServiceProfiler::getInstance(); 460 + $call_id = $profiler->beginServiceCall( 461 + array( 462 + 'type' => 'ldap', 463 + 'call' => 'bind', 464 + 'user' => $user, 465 + )); 466 + 467 + // NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep 468 + // it quiet. 469 + if (strlen($user)) { 470 + $ok = @ldap_bind($conn, $user, $pass->openEnvelope()); 471 + } else { 472 + $ok = @ldap_bind($conn); 473 + } 474 + 475 + $profiler->endServiceCall($call_id, array()); 476 + 477 + if (!$ok) { 478 + if (strlen($user)) { 479 + $this->raiseConnectionException( 480 + $conn, 481 + pht('Failed to bind to LDAP server (as user "%s").', $user)); 482 + } else { 483 + $this->raiseConnectionException( 484 + $conn, 485 + pht('Failed to bind to LDAP server (without username).')); 486 + } 487 + } 488 + } 489 + 490 + 491 + /** 492 + * Determine if this adapter should attempt to bind to the LDAP server 493 + * without a user identity. 494 + * 495 + * Generally, we can bind directly if we have a username/password, or if the 496 + * "Always Search" flag is set, indicating that the empty username and 497 + * password are sufficient. 498 + * 499 + * @return bool True if the adapter should perform binds without identity. 500 + */ 501 + private function shouldBindWithoutIdentity() { 502 + return $this->alwaysSearch || strlen($this->anonymousUsername); 503 + } 504 + 505 + }
+211
src/applications/auth/adapter/PhutilOAuth1AuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Abstract adapter for OAuth1 providers. 5 + */ 6 + abstract class PhutilOAuth1AuthAdapter extends PhutilAuthAdapter { 7 + 8 + private $consumerKey; 9 + private $consumerSecret; 10 + private $token; 11 + private $tokenSecret; 12 + private $verifier; 13 + private $handshakeData; 14 + private $callbackURI; 15 + private $privateKey; 16 + 17 + public function setPrivateKey(PhutilOpaqueEnvelope $private_key) { 18 + $this->privateKey = $private_key; 19 + return $this; 20 + } 21 + 22 + public function getPrivateKey() { 23 + return $this->privateKey; 24 + } 25 + 26 + public function setCallbackURI($callback_uri) { 27 + $this->callbackURI = $callback_uri; 28 + return $this; 29 + } 30 + 31 + public function getCallbackURI() { 32 + return $this->callbackURI; 33 + } 34 + 35 + public function setVerifier($verifier) { 36 + $this->verifier = $verifier; 37 + return $this; 38 + } 39 + 40 + public function getVerifier() { 41 + return $this->verifier; 42 + } 43 + 44 + public function setConsumerSecret(PhutilOpaqueEnvelope $consumer_secret) { 45 + $this->consumerSecret = $consumer_secret; 46 + return $this; 47 + } 48 + 49 + public function getConsumerSecret() { 50 + return $this->consumerSecret; 51 + } 52 + 53 + public function setConsumerKey($consumer_key) { 54 + $this->consumerKey = $consumer_key; 55 + return $this; 56 + } 57 + 58 + public function getConsumerKey() { 59 + return $this->consumerKey; 60 + } 61 + 62 + public function setTokenSecret($token_secret) { 63 + $this->tokenSecret = $token_secret; 64 + return $this; 65 + } 66 + 67 + public function getTokenSecret() { 68 + return $this->tokenSecret; 69 + } 70 + 71 + public function setToken($token) { 72 + $this->token = $token; 73 + return $this; 74 + } 75 + 76 + public function getToken() { 77 + return $this->token; 78 + } 79 + 80 + protected function getHandshakeData() { 81 + if ($this->handshakeData === null) { 82 + $this->finishOAuthHandshake(); 83 + } 84 + return $this->handshakeData; 85 + } 86 + 87 + abstract protected function getRequestTokenURI(); 88 + abstract protected function getAuthorizeTokenURI(); 89 + abstract protected function getValidateTokenURI(); 90 + 91 + protected function getSignatureMethod() { 92 + return 'HMAC-SHA1'; 93 + } 94 + 95 + public function getContentSecurityPolicyFormActions() { 96 + return array( 97 + $this->getAuthorizeTokenURI(), 98 + ); 99 + } 100 + 101 + protected function newOAuth1Future($uri, $data = array()) { 102 + $future = id(new PhutilOAuth1Future($uri, $data)) 103 + ->setMethod('POST') 104 + ->setSignatureMethod($this->getSignatureMethod()); 105 + 106 + $consumer_key = $this->getConsumerKey(); 107 + if (strlen($consumer_key)) { 108 + $future->setConsumerKey($consumer_key); 109 + } else { 110 + throw new Exception( 111 + pht( 112 + '%s is required!', 113 + 'setConsumerKey()')); 114 + } 115 + 116 + $consumer_secret = $this->getConsumerSecret(); 117 + if ($consumer_secret) { 118 + $future->setConsumerSecret($consumer_secret); 119 + } 120 + 121 + if (strlen($this->getToken())) { 122 + $future->setToken($this->getToken()); 123 + } 124 + 125 + if (strlen($this->getTokenSecret())) { 126 + $future->setTokenSecret($this->getTokenSecret()); 127 + } 128 + 129 + if ($this->getPrivateKey()) { 130 + $future->setPrivateKey($this->getPrivateKey()); 131 + } 132 + 133 + return $future; 134 + } 135 + 136 + public function getClientRedirectURI() { 137 + $request_token_uri = $this->getRequestTokenURI(); 138 + 139 + $future = $this->newOAuth1Future($request_token_uri); 140 + if (strlen($this->getCallbackURI())) { 141 + $future->setCallbackURI($this->getCallbackURI()); 142 + } 143 + 144 + list($body) = $future->resolvex(); 145 + $data = id(new PhutilQueryStringParser())->parseQueryString($body); 146 + 147 + // NOTE: Per the spec, this value MUST be the string 'true'. 148 + $confirmed = idx($data, 'oauth_callback_confirmed'); 149 + if ($confirmed !== 'true') { 150 + throw new Exception( 151 + pht("Expected '%s' to be '%s'!", 'oauth_callback_confirmed', 'true')); 152 + } 153 + 154 + $this->readTokenAndTokenSecret($data); 155 + 156 + $authorize_token_uri = new PhutilURI($this->getAuthorizeTokenURI()); 157 + $authorize_token_uri->replaceQueryParam('oauth_token', $this->getToken()); 158 + 159 + return (string)$authorize_token_uri; 160 + } 161 + 162 + protected function finishOAuthHandshake() { 163 + $this->willFinishOAuthHandshake(); 164 + 165 + if (!$this->getToken()) { 166 + throw new Exception(pht('Expected token to finish OAuth handshake!')); 167 + } 168 + if (!$this->getVerifier()) { 169 + throw new Exception(pht('Expected verifier to finish OAuth handshake!')); 170 + } 171 + 172 + $validate_uri = $this->getValidateTokenURI(); 173 + $params = array( 174 + 'oauth_verifier' => $this->getVerifier(), 175 + ); 176 + 177 + list($body) = $this->newOAuth1Future($validate_uri, $params)->resolvex(); 178 + $data = id(new PhutilQueryStringParser())->parseQueryString($body); 179 + 180 + $this->readTokenAndTokenSecret($data); 181 + 182 + $this->handshakeData = $data; 183 + } 184 + 185 + private function readTokenAndTokenSecret(array $data) { 186 + $token = idx($data, 'oauth_token'); 187 + if (!$token) { 188 + throw new Exception(pht("Expected '%s' in response!", 'oauth_token')); 189 + } 190 + 191 + $token_secret = idx($data, 'oauth_token_secret'); 192 + if (!$token_secret) { 193 + throw new Exception( 194 + pht("Expected '%s' in response!", 'oauth_token_secret')); 195 + } 196 + 197 + $this->setToken($token); 198 + $this->setTokenSecret($token_secret); 199 + 200 + return $this; 201 + } 202 + 203 + /** 204 + * Hook that allows subclasses to take actions before the OAuth handshake 205 + * is completed. 206 + */ 207 + protected function willFinishOAuthHandshake() { 208 + return; 209 + } 210 + 211 + }
+228
src/applications/auth/adapter/PhutilOAuthAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Abstract adapter for OAuth2 providers. 5 + */ 6 + abstract class PhutilOAuthAuthAdapter extends PhutilAuthAdapter { 7 + 8 + private $clientID; 9 + private $clientSecret; 10 + private $redirectURI; 11 + private $scope; 12 + private $state; 13 + private $code; 14 + 15 + private $accessTokenData; 16 + private $oauthAccountData; 17 + 18 + abstract protected function getAuthenticateBaseURI(); 19 + abstract protected function getTokenBaseURI(); 20 + abstract protected function loadOAuthAccountData(); 21 + 22 + public function getAuthenticateURI() { 23 + $params = array( 24 + 'client_id' => $this->getClientID(), 25 + 'scope' => $this->getScope(), 26 + 'redirect_uri' => $this->getRedirectURI(), 27 + 'state' => $this->getState(), 28 + ) + $this->getExtraAuthenticateParameters(); 29 + 30 + $uri = new PhutilURI($this->getAuthenticateBaseURI(), $params); 31 + 32 + return phutil_string_cast($uri); 33 + } 34 + 35 + public function getAdapterType() { 36 + $this_class = get_class($this); 37 + $type_name = str_replace('PhutilAuthAdapterOAuth', '', $this_class); 38 + return strtolower($type_name); 39 + } 40 + 41 + public function setState($state) { 42 + $this->state = $state; 43 + return $this; 44 + } 45 + 46 + public function getState() { 47 + return $this->state; 48 + } 49 + 50 + public function setCode($code) { 51 + $this->code = $code; 52 + return $this; 53 + } 54 + 55 + public function getCode() { 56 + return $this->code; 57 + } 58 + 59 + public function setRedirectURI($redirect_uri) { 60 + $this->redirectURI = $redirect_uri; 61 + return $this; 62 + } 63 + 64 + public function getRedirectURI() { 65 + return $this->redirectURI; 66 + } 67 + 68 + public function getExtraAuthenticateParameters() { 69 + return array(); 70 + } 71 + 72 + public function getExtraTokenParameters() { 73 + return array(); 74 + } 75 + 76 + public function getExtraRefreshParameters() { 77 + return array(); 78 + } 79 + 80 + public function setScope($scope) { 81 + $this->scope = $scope; 82 + return $this; 83 + } 84 + 85 + public function getScope() { 86 + return $this->scope; 87 + } 88 + 89 + public function setClientSecret(PhutilOpaqueEnvelope $client_secret) { 90 + $this->clientSecret = $client_secret; 91 + return $this; 92 + } 93 + 94 + public function getClientSecret() { 95 + return $this->clientSecret; 96 + } 97 + 98 + public function setClientID($client_id) { 99 + $this->clientID = $client_id; 100 + return $this; 101 + } 102 + 103 + public function getClientID() { 104 + return $this->clientID; 105 + } 106 + 107 + public function getAccessToken() { 108 + return $this->getAccessTokenData('access_token'); 109 + } 110 + 111 + public function getAccessTokenExpires() { 112 + return $this->getAccessTokenData('expires_epoch'); 113 + } 114 + 115 + public function getRefreshToken() { 116 + return $this->getAccessTokenData('refresh_token'); 117 + } 118 + 119 + protected function getAccessTokenData($key, $default = null) { 120 + if ($this->accessTokenData === null) { 121 + $this->accessTokenData = $this->loadAccessTokenData(); 122 + } 123 + 124 + return idx($this->accessTokenData, $key, $default); 125 + } 126 + 127 + public function supportsTokenRefresh() { 128 + return false; 129 + } 130 + 131 + public function refreshAccessToken($refresh_token) { 132 + $this->accessTokenData = $this->loadRefreshTokenData($refresh_token); 133 + return $this; 134 + } 135 + 136 + protected function loadRefreshTokenData($refresh_token) { 137 + $params = array( 138 + 'refresh_token' => $refresh_token, 139 + ) + $this->getExtraRefreshParameters(); 140 + 141 + // NOTE: Make sure we return the refresh_token so that subsequent 142 + // calls to getRefreshToken() return it; providers normally do not echo 143 + // it back for token refresh requests. 144 + 145 + return $this->makeTokenRequest($params) + array( 146 + 'refresh_token' => $refresh_token, 147 + ); 148 + } 149 + 150 + protected function loadAccessTokenData() { 151 + $code = $this->getCode(); 152 + if (!$code) { 153 + throw new PhutilInvalidStateException('setCode'); 154 + } 155 + 156 + $params = array( 157 + 'code' => $this->getCode(), 158 + ) + $this->getExtraTokenParameters(); 159 + 160 + return $this->makeTokenRequest($params); 161 + } 162 + 163 + private function makeTokenRequest(array $params) { 164 + $uri = $this->getTokenBaseURI(); 165 + $query_data = array( 166 + 'client_id' => $this->getClientID(), 167 + 'client_secret' => $this->getClientSecret()->openEnvelope(), 168 + 'redirect_uri' => $this->getRedirectURI(), 169 + ) + $params; 170 + 171 + $future = new HTTPSFuture($uri, $query_data); 172 + $future->setMethod('POST'); 173 + list($body) = $future->resolvex(); 174 + 175 + $data = $this->readAccessTokenResponse($body); 176 + 177 + if (isset($data['expires_in'])) { 178 + $data['expires_epoch'] = $data['expires_in']; 179 + } else if (isset($data['expires'])) { 180 + $data['expires_epoch'] = $data['expires']; 181 + } 182 + 183 + // If we got some "expires" value back, interpret it as an epoch timestamp 184 + // if it's after the year 2010 and as a relative number of seconds 185 + // otherwise. 186 + if (isset($data['expires_epoch'])) { 187 + if ($data['expires_epoch'] < (60 * 60 * 24 * 365 * 40)) { 188 + $data['expires_epoch'] += time(); 189 + } 190 + } 191 + 192 + if (isset($data['error'])) { 193 + throw new Exception(pht('Access token error: %s', $data['error'])); 194 + } 195 + 196 + return $data; 197 + } 198 + 199 + protected function readAccessTokenResponse($body) { 200 + // NOTE: Most providers either return JSON or HTTP query strings, so try 201 + // both mechanisms. If your provider does something else, override this 202 + // method. 203 + 204 + $data = json_decode($body, true); 205 + 206 + if (!is_array($data)) { 207 + $data = array(); 208 + parse_str($body, $data); 209 + } 210 + 211 + if (empty($data['access_token']) && 212 + empty($data['error'])) { 213 + throw new Exception( 214 + pht('Failed to decode OAuth access token response: %s', $body)); 215 + } 216 + 217 + return $data; 218 + } 219 + 220 + protected function getOAuthAccountData($key, $default = null) { 221 + if ($this->oauthAccountData === null) { 222 + $this->oauthAccountData = $this->loadOAuthAccountData(); 223 + } 224 + 225 + return idx($this->oauthAccountData, $key, $default); 226 + } 227 + 228 + }
+102
src/applications/auth/adapter/PhutilPhabricatorAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Phabricator OAuth2. 5 + */ 6 + final class PhutilPhabricatorAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + private $phabricatorBaseURI; 9 + private $adapterDomain; 10 + 11 + public function setPhabricatorBaseURI($uri) { 12 + $this->phabricatorBaseURI = $uri; 13 + return $this; 14 + } 15 + 16 + public function getPhabricatorBaseURI() { 17 + return $this->phabricatorBaseURI; 18 + } 19 + 20 + public function getAdapterDomain() { 21 + return $this->adapterDomain; 22 + } 23 + 24 + public function setAdapterDomain($domain) { 25 + $this->adapterDomain = $domain; 26 + return $this; 27 + } 28 + 29 + public function getAdapterType() { 30 + return 'phabricator'; 31 + } 32 + 33 + public function getAccountID() { 34 + return $this->getOAuthAccountData('phid'); 35 + } 36 + 37 + public function getAccountEmail() { 38 + return $this->getOAuthAccountData('primaryEmail'); 39 + } 40 + 41 + public function getAccountName() { 42 + return $this->getOAuthAccountData('userName'); 43 + } 44 + 45 + public function getAccountImageURI() { 46 + return $this->getOAuthAccountData('image'); 47 + } 48 + 49 + public function getAccountURI() { 50 + return $this->getOAuthAccountData('uri'); 51 + } 52 + 53 + public function getAccountRealName() { 54 + return $this->getOAuthAccountData('realName'); 55 + } 56 + 57 + protected function getAuthenticateBaseURI() { 58 + return $this->getPhabricatorURI('oauthserver/auth/'); 59 + } 60 + 61 + protected function getTokenBaseURI() { 62 + return $this->getPhabricatorURI('oauthserver/token/'); 63 + } 64 + 65 + public function getScope() { 66 + return ''; 67 + } 68 + 69 + public function getExtraAuthenticateParameters() { 70 + return array( 71 + 'response_type' => 'code', 72 + ); 73 + } 74 + 75 + public function getExtraTokenParameters() { 76 + return array( 77 + 'grant_type' => 'authorization_code', 78 + ); 79 + } 80 + 81 + protected function loadOAuthAccountData() { 82 + $uri = id(new PhutilURI($this->getPhabricatorURI('api/user.whoami'))) 83 + ->replaceQueryParam('access_token', $this->getAccessToken()); 84 + list($body) = id(new HTTPSFuture($uri))->resolvex(); 85 + 86 + try { 87 + $data = phutil_json_decode($body); 88 + return $data['result']; 89 + } catch (PhutilJSONParserException $ex) { 90 + throw new Exception( 91 + pht( 92 + 'Expected valid JSON response from Phabricator %s request.', 93 + 'user.whoami'), 94 + $ex); 95 + } 96 + } 97 + 98 + private function getPhabricatorURI($path) { 99 + return rtrim($this->phabricatorBaseURI, '/').'/'.ltrim($path, '/'); 100 + } 101 + 102 + }
+61
src/applications/auth/adapter/PhutilSlackAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Slack OAuth2. 5 + */ 6 + final class PhutilSlackAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'Slack'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'slack.com'; 14 + } 15 + 16 + public function getAccountID() { 17 + $user = $this->getOAuthAccountData('user'); 18 + return idx($user, 'id'); 19 + } 20 + 21 + public function getAccountEmail() { 22 + $user = $this->getOAuthAccountData('user'); 23 + return idx($user, 'email'); 24 + } 25 + 26 + public function getAccountImageURI() { 27 + $user = $this->getOAuthAccountData('user'); 28 + return idx($user, 'image_512'); 29 + } 30 + 31 + public function getAccountRealName() { 32 + $user = $this->getOAuthAccountData('user'); 33 + return idx($user, 'name'); 34 + } 35 + 36 + protected function getAuthenticateBaseURI() { 37 + return 'https://slack.com/oauth/authorize'; 38 + } 39 + 40 + protected function getTokenBaseURI() { 41 + return 'https://slack.com/api/oauth.access'; 42 + } 43 + 44 + public function getScope() { 45 + return 'identity.basic,identity.team,identity.avatar'; 46 + } 47 + 48 + public function getExtraAuthenticateParameters() { 49 + return array( 50 + 'response_type' => 'code', 51 + ); 52 + } 53 + 54 + protected function loadOAuthAccountData() { 55 + return id(new PhutilSlackFuture()) 56 + ->setAccessToken($this->getAccessToken()) 57 + ->setRawSlackQuery('users.identity') 58 + ->resolve(); 59 + } 60 + 61 + }
+76
src/applications/auth/adapter/PhutilTwitchAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Twitch.tv OAuth2. 5 + */ 6 + final class PhutilTwitchAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'twitch'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'twitch.tv'; 14 + } 15 + 16 + public function getAccountID() { 17 + return $this->getOAuthAccountData('_id'); 18 + } 19 + 20 + public function getAccountEmail() { 21 + return $this->getOAuthAccountData('email'); 22 + } 23 + 24 + public function getAccountName() { 25 + return $this->getOAuthAccountData('name'); 26 + } 27 + 28 + public function getAccountImageURI() { 29 + return $this->getOAuthAccountData('logo'); 30 + } 31 + 32 + public function getAccountURI() { 33 + $name = $this->getAccountName(); 34 + if ($name) { 35 + return 'http://www.twitch.tv/'.$name; 36 + } 37 + return null; 38 + } 39 + 40 + public function getAccountRealName() { 41 + return $this->getOAuthAccountData('display_name'); 42 + } 43 + 44 + protected function getAuthenticateBaseURI() { 45 + return 'https://api.twitch.tv/kraken/oauth2/authorize'; 46 + } 47 + 48 + protected function getTokenBaseURI() { 49 + return 'https://api.twitch.tv/kraken/oauth2/token'; 50 + } 51 + 52 + public function getScope() { 53 + return 'user_read'; 54 + } 55 + 56 + public function getExtraAuthenticateParameters() { 57 + return array( 58 + 'response_type' => 'code', 59 + ); 60 + } 61 + 62 + public function getExtraTokenParameters() { 63 + return array( 64 + 'grant_type' => 'authorization_code', 65 + ); 66 + } 67 + 68 + protected function loadOAuthAccountData() { 69 + return id(new PhutilTwitchFuture()) 70 + ->setClientID($this->getClientID()) 71 + ->setAccessToken($this->getAccessToken()) 72 + ->setRawTwitchQuery('user') 73 + ->resolve(); 74 + } 75 + 76 + }
+75
src/applications/auth/adapter/PhutilTwitterAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for Twitter OAuth1. 5 + */ 6 + final class PhutilTwitterAuthAdapter extends PhutilOAuth1AuthAdapter { 7 + 8 + private $userInfo; 9 + 10 + public function getAccountID() { 11 + return idx($this->getHandshakeData(), 'user_id'); 12 + } 13 + 14 + public function getAccountName() { 15 + return idx($this->getHandshakeData(), 'screen_name'); 16 + } 17 + 18 + public function getAccountURI() { 19 + $name = $this->getAccountName(); 20 + if (strlen($name)) { 21 + return 'https://twitter.com/'.$name; 22 + } 23 + return null; 24 + } 25 + 26 + public function getAccountImageURI() { 27 + $info = $this->getUserInfo(); 28 + return idx($info, 'profile_image_url'); 29 + } 30 + 31 + public function getAccountRealName() { 32 + $info = $this->getUserInfo(); 33 + return idx($info, 'name'); 34 + } 35 + 36 + public function getAdapterType() { 37 + return 'twitter'; 38 + } 39 + 40 + public function getAdapterDomain() { 41 + return 'twitter.com'; 42 + } 43 + 44 + protected function getRequestTokenURI() { 45 + return 'https://api.twitter.com/oauth/request_token'; 46 + } 47 + 48 + protected function getAuthorizeTokenURI() { 49 + return 'https://api.twitter.com/oauth/authorize'; 50 + } 51 + 52 + protected function getValidateTokenURI() { 53 + return 'https://api.twitter.com/oauth/access_token'; 54 + } 55 + 56 + private function getUserInfo() { 57 + if ($this->userInfo === null) { 58 + $params = array( 59 + 'user_id' => $this->getAccountID(), 60 + ); 61 + 62 + $uri = new PhutilURI( 63 + 'https://api.twitter.com/1.1/users/show.json', 64 + $params); 65 + 66 + $data = $this->newOAuth1Future($uri) 67 + ->setMethod('GET') 68 + ->resolveJSON(); 69 + 70 + $this->userInfo = $data; 71 + } 72 + return $this->userInfo; 73 + } 74 + 75 + }
+73
src/applications/auth/adapter/PhutilWordPressAuthAdapter.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication adapter for WordPress.com OAuth2. 5 + */ 6 + final class PhutilWordPressAuthAdapter extends PhutilOAuthAuthAdapter { 7 + 8 + public function getAdapterType() { 9 + return 'wordpress'; 10 + } 11 + 12 + public function getAdapterDomain() { 13 + return 'wordpress.com'; 14 + } 15 + 16 + public function getAccountID() { 17 + return $this->getOAuthAccountData('ID'); 18 + } 19 + 20 + public function getAccountEmail() { 21 + return $this->getOAuthAccountData('email'); 22 + } 23 + 24 + public function getAccountName() { 25 + return $this->getOAuthAccountData('username'); 26 + } 27 + 28 + public function getAccountImageURI() { 29 + return $this->getOAuthAccountData('avatar_URL'); 30 + } 31 + 32 + public function getAccountURI() { 33 + return $this->getOAuthAccountData('profile_URL'); 34 + } 35 + 36 + public function getAccountRealName() { 37 + return $this->getOAuthAccountData('display_name'); 38 + } 39 + 40 + protected function getAuthenticateBaseURI() { 41 + return 'https://public-api.wordpress.com/oauth2/authorize'; 42 + } 43 + 44 + protected function getTokenBaseURI() { 45 + return 'https://public-api.wordpress.com/oauth2/token'; 46 + } 47 + 48 + public function getScope() { 49 + return 'user_read'; 50 + } 51 + 52 + public function getExtraAuthenticateParameters() { 53 + return array( 54 + 'response_type' => 'code', 55 + 'blog_id' => 0, 56 + ); 57 + } 58 + 59 + public function getExtraTokenParameters() { 60 + return array( 61 + 'grant_type' => 'authorization_code', 62 + ); 63 + } 64 + 65 + protected function loadOAuthAccountData() { 66 + return id(new PhutilWordPressFuture()) 67 + ->setClientID($this->getClientID()) 68 + ->setAccessToken($this->getAccessToken()) 69 + ->setRawWordPressQuery('/me/') 70 + ->resolve(); 71 + } 72 + 73 + }
+6
src/applications/auth/exception/PhutilAuthConfigurationException.php
··· 1 + <?php 2 + 3 + /** 4 + * Authentication is not configured correctly. 5 + */ 6 + final class PhutilAuthConfigurationException extends PhutilAuthException {}
+6
src/applications/auth/exception/PhutilAuthCredentialException.php
··· 1 + <?php 2 + 3 + /** 4 + * The user provided invalid credentials. 5 + */ 6 + final class PhutilAuthCredentialException extends PhutilAuthException {}
+7
src/applications/auth/exception/PhutilAuthException.php
··· 1 + <?php 2 + 3 + /** 4 + * Abstract exception class for errors encountered during authentication 5 + * workflows. 6 + */ 7 + abstract class PhutilAuthException extends Exception {}
+14
src/applications/auth/exception/PhutilAuthUserAbortedException.php
··· 1 + <?php 2 + 3 + /** 4 + * The user aborted the authentication workflow, by clicking "Cancel" or "Deny" 5 + * or taking some similar action. 6 + * 7 + * For example, in OAuth/OAuth2 workflows, the authentication provider 8 + * generally presents the user with a confirmation dialog with two options, 9 + * "Approve" and "Deny". 10 + * 11 + * If an adapter detects that the user has explicitly bailed out of the 12 + * workflow, it should throw this exception. 13 + */ 14 + final class PhutilAuthUserAbortedException extends PhutilAuthException {}
+287
src/applications/calendar/parser/data/PhutilCalendarAbsoluteDateTime.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarAbsoluteDateTime 4 + extends PhutilCalendarDateTime { 5 + 6 + private $year; 7 + private $month; 8 + private $day; 9 + private $hour = 0; 10 + private $minute = 0; 11 + private $second = 0; 12 + private $timezone; 13 + 14 + public static function newFromISO8601($value, $timezone = 'UTC') { 15 + $pattern = 16 + '/^'. 17 + '(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'. 18 + '(?:'. 19 + 'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>Z)?'. 20 + ')?'. 21 + '\z/'; 22 + 23 + $matches = null; 24 + $ok = preg_match($pattern, $value, $matches); 25 + if (!$ok) { 26 + throw new Exception( 27 + pht( 28 + 'Expected ISO8601 datetime in the format "19990105T112233Z", '. 29 + 'found "%s".', 30 + $value)); 31 + } 32 + 33 + if (isset($matches['z'])) { 34 + if ($timezone != 'UTC') { 35 + throw new Exception( 36 + pht( 37 + 'ISO8601 date ends in "Z" indicating UTC, but a timezone other '. 38 + 'than UTC ("%s") was specified.', 39 + $timezone)); 40 + } 41 + } 42 + 43 + $datetime = id(new self()) 44 + ->setYear((int)$matches['y']) 45 + ->setMonth((int)$matches['m']) 46 + ->setDay((int)$matches['d']) 47 + ->setTimezone($timezone); 48 + 49 + if (isset($matches['h'])) { 50 + $datetime 51 + ->setHour((int)$matches['h']) 52 + ->setMinute((int)$matches['i']) 53 + ->setSecond((int)$matches['s']); 54 + } else { 55 + $datetime 56 + ->setIsAllDay(true); 57 + } 58 + 59 + return $datetime; 60 + } 61 + 62 + public static function newFromEpoch($epoch, $timezone = 'UTC') { 63 + $date = new DateTime('@'.$epoch); 64 + 65 + $zone = new DateTimeZone($timezone); 66 + $date->setTimezone($zone); 67 + 68 + return id(new self()) 69 + ->setYear((int)$date->format('Y')) 70 + ->setMonth((int)$date->format('m')) 71 + ->setDay((int)$date->format('d')) 72 + ->setHour((int)$date->format('H')) 73 + ->setMinute((int)$date->format('i')) 74 + ->setSecond((int)$date->format('s')) 75 + ->setTimezone($timezone); 76 + } 77 + 78 + public static function newFromDictionary(array $dict) { 79 + static $keys; 80 + if ($keys === null) { 81 + $keys = array_fuse( 82 + array( 83 + 'kind', 84 + 'year', 85 + 'month', 86 + 'day', 87 + 'hour', 88 + 'minute', 89 + 'second', 90 + 'timezone', 91 + 'isAllDay', 92 + )); 93 + } 94 + 95 + foreach ($dict as $key => $value) { 96 + if (!isset($keys[$key])) { 97 + throw new Exception( 98 + pht( 99 + 'Unexpected key "%s" in datetime dictionary, expected keys: %s.', 100 + $key, 101 + implode(', ', array_keys($keys)))); 102 + } 103 + } 104 + 105 + if (idx($dict, 'kind') !== 'absolute') { 106 + throw new Exception( 107 + pht( 108 + 'Expected key "%s" with value "%s" in datetime dictionary.', 109 + 'kind', 110 + 'absolute')); 111 + } 112 + 113 + if (!isset($dict['year'])) { 114 + throw new Exception( 115 + pht( 116 + 'Expected key "%s" in datetime dictionary.', 117 + 'year')); 118 + } 119 + 120 + $datetime = id(new self()) 121 + ->setYear(idx($dict, 'year')) 122 + ->setMonth(idx($dict, 'month', 1)) 123 + ->setDay(idx($dict, 'day', 1)) 124 + ->setHour(idx($dict, 'hour', 0)) 125 + ->setMinute(idx($dict, 'minute', 0)) 126 + ->setSecond(idx($dict, 'second', 0)) 127 + ->setTimezone(idx($dict, 'timezone')) 128 + ->setIsAllDay((bool)idx($dict, 'isAllDay', false)); 129 + 130 + return $datetime; 131 + } 132 + 133 + public function newRelativeDateTime($duration) { 134 + if (is_string($duration)) { 135 + $duration = PhutilCalendarDuration::newFromISO8601($duration); 136 + } 137 + 138 + if (!($duration instanceof PhutilCalendarDuration)) { 139 + throw new Exception( 140 + pht( 141 + 'Expected "PhutilCalendarDuration" object or ISO8601 duration '. 142 + 'string.')); 143 + } 144 + 145 + return id(new PhutilCalendarRelativeDateTime()) 146 + ->setOrigin($this) 147 + ->setDuration($duration); 148 + } 149 + 150 + public function toDictionary() { 151 + return array( 152 + 'kind' => 'absolute', 153 + 'year' => (int)$this->getYear(), 154 + 'month' => (int)$this->getMonth(), 155 + 'day' => (int)$this->getDay(), 156 + 'hour' => (int)$this->getHour(), 157 + 'minute' => (int)$this->getMinute(), 158 + 'second' => (int)$this->getSecond(), 159 + 'timezone' => $this->getTimezone(), 160 + 'isAllDay' => (bool)$this->getIsAllDay(), 161 + ); 162 + } 163 + 164 + public function setYear($year) { 165 + $this->year = $year; 166 + return $this; 167 + } 168 + 169 + public function getYear() { 170 + return $this->year; 171 + } 172 + 173 + public function setMonth($month) { 174 + $this->month = $month; 175 + return $this; 176 + } 177 + 178 + public function getMonth() { 179 + return $this->month; 180 + } 181 + 182 + public function setDay($day) { 183 + $this->day = $day; 184 + return $this; 185 + } 186 + 187 + public function getDay() { 188 + return $this->day; 189 + } 190 + 191 + public function setHour($hour) { 192 + $this->hour = $hour; 193 + return $this; 194 + } 195 + 196 + public function getHour() { 197 + return $this->hour; 198 + } 199 + 200 + public function setMinute($minute) { 201 + $this->minute = $minute; 202 + return $this; 203 + } 204 + 205 + public function getMinute() { 206 + return $this->minute; 207 + } 208 + 209 + public function setSecond($second) { 210 + $this->second = $second; 211 + return $this; 212 + } 213 + 214 + public function getSecond() { 215 + return $this->second; 216 + } 217 + 218 + public function setTimezone($timezone) { 219 + $this->timezone = $timezone; 220 + return $this; 221 + } 222 + 223 + public function getTimezone() { 224 + return $this->timezone; 225 + } 226 + 227 + private function getEffectiveTimezone() { 228 + $date_timezone = $this->getTimezone(); 229 + $viewer_timezone = $this->getViewerTimezone(); 230 + 231 + // Because all-day events are always "floating", the effective timezone 232 + // is the viewer timezone if it is available. Otherwise, we'll return a 233 + // DateTime object with the correct values, but it will be incorrectly 234 + // adjusted forward or backward to the viewer's zone later. 235 + 236 + $zones = array(); 237 + if ($this->getIsAllDay()) { 238 + $zones[] = $viewer_timezone; 239 + $zones[] = $date_timezone; 240 + } else { 241 + $zones[] = $date_timezone; 242 + $zones[] = $viewer_timezone; 243 + } 244 + $zones = array_filter($zones); 245 + 246 + if (!$zones) { 247 + throw new Exception( 248 + pht( 249 + 'Datetime has no timezone or viewer timezone.')); 250 + } 251 + 252 + return head($zones); 253 + } 254 + 255 + public function newPHPDateTimeZone() { 256 + $zone = $this->getEffectiveTimezone(); 257 + return new DateTimeZone($zone); 258 + } 259 + 260 + public function newPHPDateTime() { 261 + $zone = $this->newPHPDateTimeZone(); 262 + 263 + $y = $this->getYear(); 264 + $m = $this->getMonth(); 265 + $d = $this->getDay(); 266 + 267 + if ($this->getIsAllDay()) { 268 + $h = 0; 269 + $i = 0; 270 + $s = 0; 271 + } else { 272 + $h = $this->getHour(); 273 + $i = $this->getMinute(); 274 + $s = $this->getSecond(); 275 + } 276 + 277 + $format = sprintf('%04d-%02d-%02d %02d:%02d:%02d', $y, $m, $d, $h, $i, $s); 278 + 279 + return new DateTime($format, $zone); 280 + } 281 + 282 + 283 + public function newAbsoluteDateTime() { 284 + return clone $this; 285 + } 286 + 287 + }
+30
src/applications/calendar/parser/data/PhutilCalendarContainerNode.php
··· 1 + <?php 2 + 3 + abstract class PhutilCalendarContainerNode 4 + extends PhutilCalendarNode { 5 + 6 + private $children = array(); 7 + 8 + final public function getChildren() { 9 + return $this->children; 10 + } 11 + 12 + final public function getChildrenOfType($type) { 13 + $result = array(); 14 + 15 + foreach ($this->getChildren() as $key => $child) { 16 + if ($child->getNodeType() != $type) { 17 + continue; 18 + } 19 + $result[$key] = $child; 20 + } 21 + 22 + return $result; 23 + } 24 + 25 + final public function appendChild(PhutilCalendarNode $node) { 26 + $this->children[] = $node; 27 + return $this; 28 + } 29 + 30 + }
+54
src/applications/calendar/parser/data/PhutilCalendarDateTime.php
··· 1 + <?php 2 + 3 + abstract class PhutilCalendarDateTime 4 + extends Phobject { 5 + 6 + private $viewerTimezone; 7 + private $isAllDay = false; 8 + 9 + public function setViewerTimezone($viewer_timezone) { 10 + $this->viewerTimezone = $viewer_timezone; 11 + return $this; 12 + } 13 + 14 + public function getViewerTimezone() { 15 + return $this->viewerTimezone; 16 + } 17 + 18 + public function setIsAllDay($is_all_day) { 19 + $this->isAllDay = $is_all_day; 20 + return $this; 21 + } 22 + 23 + public function getIsAllDay() { 24 + return $this->isAllDay; 25 + } 26 + 27 + public function getEpoch() { 28 + $datetime = $this->newPHPDateTime(); 29 + return (int)$datetime->format('U'); 30 + } 31 + 32 + public function getISO8601() { 33 + $datetime = $this->newPHPDateTime(); 34 + 35 + if ($this->getIsAllDay()) { 36 + return $datetime->format('Ymd'); 37 + } else if ($this->getTimezone()) { 38 + // With a timezone, the event occurs at a specific second universally. 39 + // We return the UTC representation of that point in time. 40 + $datetime->setTimezone(new DateTimeZone('UTC')); 41 + return $datetime->format('Ymd\\THis\\Z'); 42 + } else { 43 + // With no timezone, events are "floating" and occur at local time. 44 + // We return a representation without the "Z". 45 + return $datetime->format('Ymd\\THis'); 46 + } 47 + } 48 + 49 + abstract public function newPHPDateTimeZone(); 50 + abstract public function newPHPDateTime(); 51 + abstract public function newAbsoluteDateTime(); 52 + 53 + abstract public function getTimezone(); 54 + }
+12
src/applications/calendar/parser/data/PhutilCalendarDocumentNode.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarDocumentNode 4 + extends PhutilCalendarContainerNode { 5 + 6 + const NODETYPE = 'document'; 7 + 8 + public function getEvents() { 9 + return $this->getChildrenOfType(PhutilCalendarEventNode::NODETYPE); 10 + } 11 + 12 + }
+181
src/applications/calendar/parser/data/PhutilCalendarDuration.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarDuration extends Phobject { 4 + 5 + private $isNegative = false; 6 + private $weeks = 0; 7 + private $days = 0; 8 + private $hours = 0; 9 + private $minutes = 0; 10 + private $seconds = 0; 11 + 12 + public static function newFromDictionary(array $dict) { 13 + static $keys; 14 + if ($keys === null) { 15 + $keys = array_fuse( 16 + array( 17 + 'isNegative', 18 + 'weeks', 19 + 'days', 20 + 'hours', 21 + 'minutes', 22 + 'seconds', 23 + )); 24 + } 25 + 26 + foreach ($dict as $key => $value) { 27 + if (!isset($keys[$key])) { 28 + throw new Exception( 29 + pht( 30 + 'Unexpected key "%s" in duration dictionary, expected keys: %s.', 31 + $key, 32 + implode(', ', array_keys($keys)))); 33 + } 34 + } 35 + 36 + $duration = id(new self()) 37 + ->setIsNegative(idx($dict, 'isNegative', false)) 38 + ->setWeeks(idx($dict, 'weeks', 0)) 39 + ->setDays(idx($dict, 'days', 0)) 40 + ->setHours(idx($dict, 'hours', 0)) 41 + ->setMinutes(idx($dict, 'minutes', 0)) 42 + ->setSeconds(idx($dict, 'seconds', 0)); 43 + 44 + return $duration; 45 + } 46 + 47 + public function toDictionary() { 48 + return array( 49 + 'isNegative' => $this->getIsNegative(), 50 + 'weeks' => $this->getWeeks(), 51 + 'days' => $this->getDays(), 52 + 'hours' => $this->getHours(), 53 + 'minutes' => $this->getMinutes(), 54 + 'seconds' => $this->getSeconds(), 55 + ); 56 + } 57 + 58 + public static function newFromISO8601($value) { 59 + $pattern = 60 + '/^'. 61 + '(?P<sign>[+-])?'. 62 + 'P'. 63 + '(?:'. 64 + '(?P<W>\d+)W'. 65 + '|'. 66 + '(?:(?:(?P<D>\d+)D)?'. 67 + '(?:T(?:(?P<H>\d+)H)?(?:(?P<M>\d+)M)?(?:(?P<S>\d+)S)?)?'. 68 + ')'. 69 + ')'. 70 + '\z/'; 71 + 72 + $matches = null; 73 + $ok = preg_match($pattern, $value, $matches); 74 + if (!$ok) { 75 + throw new Exception( 76 + pht( 77 + 'Expected ISO8601 duration in the format "P12DT3H4M5S", found '. 78 + '"%s".', 79 + $value)); 80 + } 81 + 82 + $is_negative = (idx($matches, 'sign') == '-'); 83 + 84 + return id(new self()) 85 + ->setIsNegative($is_negative) 86 + ->setWeeks((int)idx($matches, 'W', 0)) 87 + ->setDays((int)idx($matches, 'D', 0)) 88 + ->setHours((int)idx($matches, 'H', 0)) 89 + ->setMinutes((int)idx($matches, 'M', 0)) 90 + ->setSeconds((int)idx($matches, 'S', 0)); 91 + } 92 + 93 + public function toISO8601() { 94 + $parts = array(); 95 + $parts[] = 'P'; 96 + 97 + $weeks = $this->getWeeks(); 98 + if ($weeks) { 99 + $parts[] = $weeks.'W'; 100 + } else { 101 + $days = $this->getDays(); 102 + if ($days) { 103 + $parts[] = $days.'D'; 104 + } 105 + 106 + $parts[] = 'T'; 107 + 108 + $hours = $this->getHours(); 109 + if ($hours) { 110 + $parts[] = $hours.'H'; 111 + } 112 + 113 + $minutes = $this->getMinutes(); 114 + if ($minutes) { 115 + $parts[] = $minutes.'M'; 116 + } 117 + 118 + $seconds = $this->getSeconds(); 119 + if ($seconds) { 120 + $parts[] = $seconds.'S'; 121 + } 122 + } 123 + 124 + return implode('', $parts); 125 + } 126 + 127 + public function setIsNegative($is_negative) { 128 + $this->isNegative = $is_negative; 129 + return $this; 130 + } 131 + 132 + public function getIsNegative() { 133 + return $this->isNegative; 134 + } 135 + 136 + public function setWeeks($weeks) { 137 + $this->weeks = $weeks; 138 + return $this; 139 + } 140 + 141 + public function getWeeks() { 142 + return $this->weeks; 143 + } 144 + 145 + public function setDays($days) { 146 + $this->days = $days; 147 + return $this; 148 + } 149 + 150 + public function getDays() { 151 + return $this->days; 152 + } 153 + 154 + public function setHours($hours) { 155 + $this->hours = $hours; 156 + return $this; 157 + } 158 + 159 + public function getHours() { 160 + return $this->hours; 161 + } 162 + 163 + public function setMinutes($minutes) { 164 + $this->minutes = $minutes; 165 + return $this; 166 + } 167 + 168 + public function getMinutes() { 169 + return $this->minutes; 170 + } 171 + 172 + public function setSeconds($seconds) { 173 + $this->seconds = $seconds; 174 + return $this; 175 + } 176 + 177 + public function getSeconds() { 178 + return $this->seconds; 179 + } 180 + 181 + }
+172
src/applications/calendar/parser/data/PhutilCalendarEventNode.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarEventNode 4 + extends PhutilCalendarContainerNode { 5 + 6 + const NODETYPE = 'event'; 7 + 8 + private $uid; 9 + private $name; 10 + private $description; 11 + private $startDateTime; 12 + private $endDateTime; 13 + private $duration; 14 + private $createdDateTime; 15 + private $modifiedDateTime; 16 + private $organizer; 17 + private $attendees = array(); 18 + private $recurrenceRule; 19 + private $recurrenceExceptions = array(); 20 + private $recurrenceDates = array(); 21 + private $recurrenceID; 22 + 23 + public function setUID($uid) { 24 + $this->uid = $uid; 25 + return $this; 26 + } 27 + 28 + public function getUID() { 29 + return $this->uid; 30 + } 31 + 32 + public function setName($name) { 33 + $this->name = $name; 34 + return $this; 35 + } 36 + 37 + public function getName() { 38 + return $this->name; 39 + } 40 + 41 + public function setDescription($description) { 42 + $this->description = $description; 43 + return $this; 44 + } 45 + 46 + public function getDescription() { 47 + return $this->description; 48 + } 49 + 50 + public function setStartDateTime(PhutilCalendarDateTime $start) { 51 + $this->startDateTime = $start; 52 + return $this; 53 + } 54 + 55 + public function getStartDateTime() { 56 + return $this->startDateTime; 57 + } 58 + 59 + public function setEndDateTime(PhutilCalendarDateTime $end) { 60 + $this->endDateTime = $end; 61 + return $this; 62 + } 63 + 64 + public function getEndDateTime() { 65 + $end = $this->endDateTime; 66 + if ($end) { 67 + return $end; 68 + } 69 + 70 + $start = $this->getStartDateTime(); 71 + $duration = $this->getDuration(); 72 + if ($start && $duration) { 73 + return id(new PhutilCalendarRelativeDateTime()) 74 + ->setOrigin($start) 75 + ->setDuration($duration); 76 + } 77 + 78 + // If no end date or duration are specified, the event is instantaneous. 79 + return $start; 80 + } 81 + 82 + public function setDuration(PhutilCalendarDuration $duration) { 83 + $this->duration = $duration; 84 + return $this; 85 + } 86 + 87 + public function getDuration() { 88 + return $this->duration; 89 + } 90 + 91 + public function setCreatedDateTime(PhutilCalendarDateTime $created) { 92 + $this->createdDateTime = $created; 93 + return $this; 94 + } 95 + 96 + public function getCreatedDateTime() { 97 + return $this->createdDateTime; 98 + } 99 + 100 + public function setModifiedDateTime(PhutilCalendarDateTime $modified) { 101 + $this->modifiedDateTime = $modified; 102 + return $this; 103 + } 104 + 105 + public function getModifiedDateTime() { 106 + return $this->modifiedDateTime; 107 + } 108 + 109 + public function setOrganizer(PhutilCalendarUserNode $organizer) { 110 + $this->organizer = $organizer; 111 + return $this; 112 + } 113 + 114 + public function getOrganizer() { 115 + return $this->organizer; 116 + } 117 + 118 + public function setAttendees(array $attendees) { 119 + assert_instances_of($attendees, 'PhutilCalendarUserNode'); 120 + $this->attendees = $attendees; 121 + return $this; 122 + } 123 + 124 + public function getAttendees() { 125 + return $this->attendees; 126 + } 127 + 128 + public function addAttendee(PhutilCalendarUserNode $attendee) { 129 + $this->attendees[] = $attendee; 130 + return $this; 131 + } 132 + 133 + public function setRecurrenceRule( 134 + PhutilCalendarRecurrenceRule $recurrence_rule) { 135 + $this->recurrenceRule = $recurrence_rule; 136 + return $this; 137 + } 138 + 139 + public function getRecurrenceRule() { 140 + return $this->recurrenceRule; 141 + } 142 + 143 + public function setRecurrenceExceptions(array $recurrence_exceptions) { 144 + assert_instances_of($recurrence_exceptions, 'PhutilCalendarDateTime'); 145 + $this->recurrenceExceptions = $recurrence_exceptions; 146 + return $this; 147 + } 148 + 149 + public function getRecurrenceExceptions() { 150 + return $this->recurrenceExceptions; 151 + } 152 + 153 + public function setRecurrenceDates(array $recurrence_dates) { 154 + assert_instances_of($recurrence_dates, 'PhutilCalendarDateTime'); 155 + $this->recurrenceDates = $recurrence_dates; 156 + return $this; 157 + } 158 + 159 + public function getRecurrenceDates() { 160 + return $this->recurrenceDates; 161 + } 162 + 163 + public function setRecurrenceID($recurrence_id) { 164 + $this->recurrenceID = $recurrence_id; 165 + return $this; 166 + } 167 + 168 + public function getRecurrenceID() { 169 + return $this->recurrenceID; 170 + } 171 + 172 + }
+20
src/applications/calendar/parser/data/PhutilCalendarNode.php
··· 1 + <?php 2 + 3 + abstract class PhutilCalendarNode extends Phobject { 4 + 5 + private $attributes = array(); 6 + 7 + final public function getNodeType() { 8 + return $this->getPhobjectClassConstant('NODETYPE'); 9 + } 10 + 11 + final public function setAttribute($key, $value) { 12 + $this->attributes[$key] = $value; 13 + return $this; 14 + } 15 + 16 + final public function getAttribute($key, $default = null) { 17 + return idx($this->attributes, $key, $default); 18 + } 19 + 20 + }
+51
src/applications/calendar/parser/data/PhutilCalendarProxyDateTime.php
··· 1 + <?php 2 + 3 + abstract class PhutilCalendarProxyDateTime 4 + extends PhutilCalendarDateTime { 5 + 6 + private $proxy; 7 + 8 + final protected function setProxy(PhutilCalendarDateTime $proxy) { 9 + $this->proxy = $proxy; 10 + return $this; 11 + } 12 + 13 + final protected function getProxy() { 14 + return $this->proxy; 15 + } 16 + 17 + public function __clone() { 18 + $this->proxy = clone $this->proxy; 19 + } 20 + 21 + public function setViewerTimezone($timezone) { 22 + $this->getProxy()->setViewerTimezone($timezone); 23 + return $this; 24 + } 25 + 26 + public function getViewerTimezone() { 27 + return $this->getProxy()->getViewerTimezone(); 28 + } 29 + 30 + public function setIsAllDay($is_all_day) { 31 + $this->getProxy()->setIsAllDay($is_all_day); 32 + return $this; 33 + } 34 + 35 + public function getIsAllDay() { 36 + return $this->getProxy()->getIsAllDay(); 37 + } 38 + 39 + public function newPHPDateTimezone() { 40 + return $this->getProxy()->newPHPDateTimezone(); 41 + } 42 + 43 + public function newPHPDateTime() { 44 + return $this->getProxy()->newPHPDateTime(); 45 + } 46 + 47 + public function getTimezone() { 48 + return $this->getProxy()->getTimezone(); 49 + } 50 + 51 + }
+8
src/applications/calendar/parser/data/PhutilCalendarRawNode.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRawNode 4 + extends PhutilCalendarContainerNode { 5 + 6 + const NODETYPE = 'raw'; 7 + 8 + }
+43
src/applications/calendar/parser/data/PhutilCalendarRecurrenceList.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRecurrenceList 4 + extends PhutilCalendarRecurrenceSource { 5 + 6 + private $dates = array(); 7 + private $order; 8 + 9 + public function setDates(array $dates) { 10 + assert_instances_of($dates, 'PhutilCalendarDateTime'); 11 + $this->dates = $dates; 12 + return $this; 13 + } 14 + 15 + public function getDates() { 16 + return $this->dates; 17 + } 18 + 19 + public function resetSource() { 20 + foreach ($this->getDates() as $date) { 21 + $date->setViewerTimezone($this->getViewerTimezone()); 22 + } 23 + 24 + $order = msort($this->getDates(), 'getEpoch'); 25 + $order = array_reverse($order); 26 + $this->order = $order; 27 + 28 + return $this; 29 + } 30 + 31 + public function getNextEvent($cursor) { 32 + while ($this->order) { 33 + $next = array_pop($this->order); 34 + if ($next->getEpoch() >= $cursor) { 35 + return $next; 36 + } 37 + } 38 + 39 + return null; 40 + } 41 + 42 + 43 + }
+1820
src/applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRecurrenceRule 4 + extends PhutilCalendarRecurrenceSource { 5 + 6 + private $startDateTime; 7 + private $frequency; 8 + private $frequencyScale; 9 + private $interval = 1; 10 + private $bySecond = array(); 11 + private $byMinute = array(); 12 + private $byHour = array(); 13 + private $byDay = array(); 14 + private $byMonthDay = array(); 15 + private $byYearDay = array(); 16 + private $byWeekNumber = array(); 17 + private $byMonth = array(); 18 + private $bySetPosition = array(); 19 + private $weekStart = self::WEEKDAY_MONDAY; 20 + private $count; 21 + private $until; 22 + 23 + private $cursorSecond; 24 + private $cursorMinute; 25 + private $cursorHour; 26 + private $cursorHourState; 27 + private $cursorWeek; 28 + private $cursorWeekday; 29 + private $cursorWeekState; 30 + private $cursorDay; 31 + private $cursorDayState; 32 + private $cursorMonth; 33 + private $cursorYear; 34 + 35 + private $setSeconds; 36 + private $setMinutes; 37 + private $setHours; 38 + private $setDays; 39 + private $setMonths; 40 + private $setWeeks; 41 + private $setYears; 42 + 43 + private $stateSecond; 44 + private $stateMinute; 45 + private $stateHour; 46 + private $stateDay; 47 + private $stateWeek; 48 + private $stateMonth; 49 + private $stateYear; 50 + 51 + private $baseYear; 52 + private $isAllDay; 53 + private $activeSet = array(); 54 + private $nextSet = array(); 55 + private $minimumEpoch; 56 + 57 + const FREQUENCY_SECONDLY = 'SECONDLY'; 58 + const FREQUENCY_MINUTELY = 'MINUTELY'; 59 + const FREQUENCY_HOURLY = 'HOURLY'; 60 + const FREQUENCY_DAILY = 'DAILY'; 61 + const FREQUENCY_WEEKLY = 'WEEKLY'; 62 + const FREQUENCY_MONTHLY = 'MONTHLY'; 63 + const FREQUENCY_YEARLY = 'YEARLY'; 64 + 65 + const SCALE_SECONDLY = 1; 66 + const SCALE_MINUTELY = 2; 67 + const SCALE_HOURLY = 3; 68 + const SCALE_DAILY = 4; 69 + const SCALE_WEEKLY = 5; 70 + const SCALE_MONTHLY = 6; 71 + const SCALE_YEARLY = 7; 72 + 73 + const WEEKDAY_SUNDAY = 'SU'; 74 + const WEEKDAY_MONDAY = 'MO'; 75 + const WEEKDAY_TUESDAY = 'TU'; 76 + const WEEKDAY_WEDNESDAY = 'WE'; 77 + const WEEKDAY_THURSDAY = 'TH'; 78 + const WEEKDAY_FRIDAY = 'FR'; 79 + const WEEKDAY_SATURDAY = 'SA'; 80 + 81 + const WEEKINDEX_SUNDAY = 0; 82 + const WEEKINDEX_MONDAY = 1; 83 + const WEEKINDEX_TUESDAY = 2; 84 + const WEEKINDEX_WEDNESDAY = 3; 85 + const WEEKINDEX_THURSDAY = 4; 86 + const WEEKINDEX_FRIDAY = 5; 87 + const WEEKINDEX_SATURDAY = 6; 88 + 89 + public function toDictionary() { 90 + $parts = array(); 91 + 92 + $parts['FREQ'] = $this->getFrequency(); 93 + 94 + $interval = $this->getInterval(); 95 + if ($interval != 1) { 96 + $parts['INTERVAL'] = $interval; 97 + } 98 + 99 + $by_second = $this->getBySecond(); 100 + if ($by_second) { 101 + $parts['BYSECOND'] = $by_second; 102 + } 103 + 104 + $by_minute = $this->getByMinute(); 105 + if ($by_minute) { 106 + $parts['BYMINUTE'] = $by_minute; 107 + } 108 + 109 + $by_hour = $this->getByHour(); 110 + if ($by_hour) { 111 + $parts['BYHOUR'] = $by_hour; 112 + } 113 + 114 + $by_day = $this->getByDay(); 115 + if ($by_day) { 116 + $parts['BYDAY'] = $by_day; 117 + } 118 + 119 + $by_month = $this->getByMonth(); 120 + if ($by_month) { 121 + $parts['BYMONTH'] = $by_month; 122 + } 123 + 124 + $by_monthday = $this->getByMonthDay(); 125 + if ($by_monthday) { 126 + $parts['BYMONTHDAY'] = $by_monthday; 127 + } 128 + 129 + $by_yearday = $this->getByYearDay(); 130 + if ($by_yearday) { 131 + $parts['BYYEARDAY'] = $by_yearday; 132 + } 133 + 134 + $by_weekno = $this->getByWeekNumber(); 135 + if ($by_weekno) { 136 + $parts['BYWEEKNO'] = $by_weekno; 137 + } 138 + 139 + $by_setpos = $this->getBySetPosition(); 140 + if ($by_setpos) { 141 + $parts['BYSETPOS'] = $by_setpos; 142 + } 143 + 144 + $wkst = $this->getWeekStart(); 145 + if ($wkst != self::WEEKDAY_MONDAY) { 146 + $parts['WKST'] = $wkst; 147 + } 148 + 149 + $count = $this->getCount(); 150 + if ($count) { 151 + $parts['COUNT'] = $count; 152 + } 153 + 154 + $until = $this->getUntil(); 155 + if ($until) { 156 + $parts['UNTIL'] = $until->getISO8601(); 157 + } 158 + 159 + return $parts; 160 + } 161 + 162 + public static function newFromDictionary(array $dict) { 163 + static $expect; 164 + if ($expect === null) { 165 + $expect = array_fuse( 166 + array( 167 + 'FREQ', 168 + 'INTERVAL', 169 + 'BYSECOND', 170 + 'BYMINUTE', 171 + 'BYHOUR', 172 + 'BYDAY', 173 + 'BYMONTH', 174 + 'BYMONTHDAY', 175 + 'BYYEARDAY', 176 + 'BYWEEKNO', 177 + 'BYSETPOS', 178 + 'WKST', 179 + 'UNTIL', 180 + 'COUNT', 181 + )); 182 + } 183 + 184 + foreach ($dict as $key => $value) { 185 + if (empty($expect[$key])) { 186 + throw new Exception( 187 + pht( 188 + 'RRULE dictionary includes unknown key "%s". Expected keys '. 189 + 'are: %s.', 190 + $key, 191 + implode(', ', array_keys($expect)))); 192 + } 193 + } 194 + 195 + $rrule = id(new self()) 196 + ->setFrequency(idx($dict, 'FREQ')) 197 + ->setInterval(idx($dict, 'INTERVAL', 1)) 198 + ->setBySecond(idx($dict, 'BYSECOND', array())) 199 + ->setByMinute(idx($dict, 'BYMINUTE', array())) 200 + ->setByHour(idx($dict, 'BYHOUR', array())) 201 + ->setByDay(idx($dict, 'BYDAY', array())) 202 + ->setByMonth(idx($dict, 'BYMONTH', array())) 203 + ->setByMonthDay(idx($dict, 'BYMONTHDAY', array())) 204 + ->setByYearDay(idx($dict, 'BYYEARDAY', array())) 205 + ->setByWeekNumber(idx($dict, 'BYWEEKNO', array())) 206 + ->setBySetPosition(idx($dict, 'BYSETPOS', array())) 207 + ->setWeekStart(idx($dict, 'WKST', self::WEEKDAY_MONDAY)); 208 + 209 + $count = idx($dict, 'COUNT'); 210 + if ($count) { 211 + $rrule->setCount($count); 212 + } 213 + 214 + $until = idx($dict, 'UNTIL'); 215 + if ($until) { 216 + $until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until); 217 + $rrule->setUntil($until); 218 + } 219 + 220 + return $rrule; 221 + } 222 + 223 + public function toRRULE() { 224 + $dict = $this->toDictionary(); 225 + 226 + $parts = array(); 227 + foreach ($dict as $key => $value) { 228 + if (is_array($value)) { 229 + $value = implode(',', $value); 230 + } 231 + $parts[] = "{$key}={$value}"; 232 + } 233 + 234 + return implode(';', $parts); 235 + } 236 + 237 + public static function newFromRRULE($rrule) { 238 + $parts = explode(';', $rrule); 239 + 240 + $dict = array(); 241 + foreach ($parts as $part) { 242 + list($key, $value) = explode('=', $part, 2); 243 + switch ($key) { 244 + case 'FREQ': 245 + case 'INTERVAL': 246 + case 'WKST': 247 + case 'COUNT': 248 + case 'UNTIL'; 249 + break; 250 + default: 251 + $value = explode(',', $value); 252 + break; 253 + } 254 + $dict[$key] = $value; 255 + } 256 + 257 + $int_lists = array_fuse( 258 + array( 259 + // NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE". 260 + 'BYSECOND', 261 + 'BYMINUTE', 262 + 'BYHOUR', 263 + 'BYMONTH', 264 + 'BYMONTHDAY', 265 + 'BYYEARDAY', 266 + 'BYWEEKNO', 267 + 'BYSETPOS', 268 + )); 269 + 270 + $int_values = array_fuse( 271 + array( 272 + 'COUNT', 273 + 'INTERVAL', 274 + )); 275 + 276 + foreach ($dict as $key => $value) { 277 + if (isset($int_values[$key])) { 278 + // None of these values may be negative. 279 + if (!preg_match('/^\d+\z/', $value)) { 280 + throw new Exception( 281 + pht( 282 + 'Unexpected value "%s" in "%s" RULE property: expected an '. 283 + 'integer.', 284 + $value, 285 + $key)); 286 + } 287 + $dict[$key] = (int)$value; 288 + } 289 + 290 + if (isset($int_lists[$key])) { 291 + foreach ($value as $k => $v) { 292 + if (!preg_match('/^-?\d+\z/', $v)) { 293 + throw new Exception( 294 + pht( 295 + 'Unexpected value "%s" in "%s" RRULE property: expected '. 296 + 'only integers.', 297 + $v, 298 + $key)); 299 + } 300 + $value[$k] = (int)$v; 301 + } 302 + $dict[$key] = $value; 303 + } 304 + } 305 + 306 + return self::newFromDictionary($dict); 307 + } 308 + 309 + private static function getAllWeekdayConstants() { 310 + return array_keys(self::getWeekdayIndexMap()); 311 + } 312 + 313 + private static function getWeekdayIndexMap() { 314 + static $map = array( 315 + self::WEEKDAY_SUNDAY => self::WEEKINDEX_SUNDAY, 316 + self::WEEKDAY_MONDAY => self::WEEKINDEX_MONDAY, 317 + self::WEEKDAY_TUESDAY => self::WEEKINDEX_TUESDAY, 318 + self::WEEKDAY_WEDNESDAY => self::WEEKINDEX_WEDNESDAY, 319 + self::WEEKDAY_THURSDAY => self::WEEKINDEX_THURSDAY, 320 + self::WEEKDAY_FRIDAY => self::WEEKINDEX_FRIDAY, 321 + self::WEEKDAY_SATURDAY => self::WEEKINDEX_SATURDAY, 322 + ); 323 + 324 + return $map; 325 + } 326 + 327 + private static function getWeekdayIndex($weekday) { 328 + $map = self::getWeekdayIndexMap(); 329 + if (!isset($map[$weekday])) { 330 + $constants = array_keys($map); 331 + throw new Exception( 332 + pht( 333 + 'Weekday "%s" is not a valid weekday constant. Valid constants '. 334 + 'are: %s.', 335 + $weekday, 336 + implode(', ', $constants))); 337 + } 338 + 339 + return $map[$weekday]; 340 + } 341 + 342 + public function setStartDateTime(PhutilCalendarDateTime $start) { 343 + $this->startDateTime = $start; 344 + return $this; 345 + } 346 + 347 + public function getStartDateTime() { 348 + return $this->startDateTime; 349 + } 350 + 351 + public function setCount($count) { 352 + if ($count < 1) { 353 + throw new Exception( 354 + pht( 355 + 'RRULE COUNT value "%s" is invalid: count must be at least 1.', 356 + $count)); 357 + } 358 + 359 + $this->count = $count; 360 + return $this; 361 + } 362 + 363 + public function getCount() { 364 + return $this->count; 365 + } 366 + 367 + public function setUntil(PhutilCalendarDateTime $until) { 368 + $this->until = $until; 369 + return $this; 370 + } 371 + 372 + public function getUntil() { 373 + return $this->until; 374 + } 375 + 376 + public function setFrequency($frequency) { 377 + static $map = array( 378 + self::FREQUENCY_SECONDLY => self::SCALE_SECONDLY, 379 + self::FREQUENCY_MINUTELY => self::SCALE_MINUTELY, 380 + self::FREQUENCY_HOURLY => self::SCALE_HOURLY, 381 + self::FREQUENCY_DAILY => self::SCALE_DAILY, 382 + self::FREQUENCY_WEEKLY => self::SCALE_WEEKLY, 383 + self::FREQUENCY_MONTHLY => self::SCALE_MONTHLY, 384 + self::FREQUENCY_YEARLY => self::SCALE_YEARLY, 385 + ); 386 + 387 + if (empty($map[$frequency])) { 388 + throw new Exception( 389 + pht( 390 + 'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.', 391 + $frequency, 392 + implode(', ', array_keys($map)))); 393 + } 394 + 395 + $this->frequency = $frequency; 396 + $this->frequencyScale = $map[$frequency]; 397 + 398 + return $this; 399 + } 400 + 401 + public function getFrequency() { 402 + return $this->frequency; 403 + } 404 + 405 + public function getFrequencyScale() { 406 + return $this->frequencyScale; 407 + } 408 + 409 + public function setInterval($interval) { 410 + if (!is_int($interval)) { 411 + throw new Exception( 412 + pht( 413 + 'RRULE INTERVAL "%s" is invalid: interval must be an integer.', 414 + $interval)); 415 + } 416 + 417 + if ($interval < 1) { 418 + throw new Exception( 419 + pht( 420 + 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.', 421 + $interval)); 422 + } 423 + 424 + $this->interval = $interval; 425 + return $this; 426 + } 427 + 428 + public function getInterval() { 429 + return $this->interval; 430 + } 431 + 432 + public function setBySecond(array $by_second) { 433 + $this->assertByRange('BYSECOND', $by_second, 0, 60); 434 + $this->bySecond = array_fuse($by_second); 435 + return $this; 436 + } 437 + 438 + public function getBySecond() { 439 + return $this->bySecond; 440 + } 441 + 442 + public function setByMinute(array $by_minute) { 443 + $this->assertByRange('BYMINUTE', $by_minute, 0, 59); 444 + $this->byMinute = array_fuse($by_minute); 445 + return $this; 446 + } 447 + 448 + public function getByMinute() { 449 + return $this->byMinute; 450 + } 451 + 452 + public function setByHour(array $by_hour) { 453 + $this->assertByRange('BYHOUR', $by_hour, 0, 23); 454 + $this->byHour = array_fuse($by_hour); 455 + return $this; 456 + } 457 + 458 + public function getByHour() { 459 + return $this->byHour; 460 + } 461 + 462 + public function setByDay(array $by_day) { 463 + $constants = self::getAllWeekdayConstants(); 464 + $constants = implode('|', $constants); 465 + 466 + $pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/'; 467 + foreach ($by_day as $key => $value) { 468 + $matches = null; 469 + if (!preg_match($pattern, $value, $matches)) { 470 + throw new Exception( 471 + pht( 472 + 'RRULE BYDAY value "%s" is invalid: rule part must be in the '. 473 + 'expected form (like "MO", "-3TH", or "+2SU").', 474 + $value)); 475 + } 476 + 477 + // The maximum allowed value is 53, which corresponds to "the 53rd 478 + // Monday every year" or similar when evaluated against a YEARLY rule. 479 + 480 + $maximum = 53; 481 + $magnitude = (int)$matches[1]; 482 + if ($magnitude > $maximum) { 483 + throw new Exception( 484 + pht( 485 + 'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '. 486 + 'the maximum permitted value is "%s".', 487 + $value, 488 + $magnitude, 489 + $maximum)); 490 + } 491 + 492 + // Normalize "+3FR" into "3FR". 493 + $by_day[$key] = ltrim($value, '+'); 494 + } 495 + 496 + $this->byDay = array_fuse($by_day); 497 + return $this; 498 + } 499 + 500 + public function getByDay() { 501 + return $this->byDay; 502 + } 503 + 504 + public function setByMonthDay(array $by_month_day) { 505 + $this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false); 506 + $this->byMonthDay = array_fuse($by_month_day); 507 + return $this; 508 + } 509 + 510 + public function getByMonthDay() { 511 + return $this->byMonthDay; 512 + } 513 + 514 + public function setByYearDay($by_year_day) { 515 + $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false); 516 + $this->byYearDay = array_fuse($by_year_day); 517 + return $this; 518 + } 519 + 520 + public function getByYearDay() { 521 + return $this->byYearDay; 522 + } 523 + 524 + public function setByMonth(array $by_month) { 525 + $this->assertByRange('BYMONTH', $by_month, 1, 12); 526 + $this->byMonth = array_fuse($by_month); 527 + return $this; 528 + } 529 + 530 + public function getByMonth() { 531 + return $this->byMonth; 532 + } 533 + 534 + public function setByWeekNumber(array $by_week_number) { 535 + $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false); 536 + $this->byWeekNumber = array_fuse($by_week_number); 537 + return $this; 538 + } 539 + 540 + public function getByWeekNumber() { 541 + return $this->byWeekNumber; 542 + } 543 + 544 + public function setBySetPosition(array $by_set_position) { 545 + $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false); 546 + $this->bySetPosition = $by_set_position; 547 + return $this; 548 + } 549 + 550 + public function getBySetPosition() { 551 + return $this->bySetPosition; 552 + } 553 + 554 + public function setWeekStart($week_start) { 555 + // Make sure this is a valid weekday constant. 556 + self::getWeekdayIndex($week_start); 557 + 558 + $this->weekStart = $week_start; 559 + return $this; 560 + } 561 + 562 + public function getWeekStart() { 563 + return $this->weekStart; 564 + } 565 + 566 + public function resetSource() { 567 + $frequency = $this->getFrequency(); 568 + 569 + if ($this->getByMonthDay()) { 570 + switch ($frequency) { 571 + case self::FREQUENCY_WEEKLY: 572 + // RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the 573 + // FREQ rule part is set to WEEKLY." 574 + throw new Exception( 575 + pht( 576 + 'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '. 577 + 'violates RFC5545.')); 578 + break; 579 + default: 580 + break; 581 + } 582 + 583 + } 584 + 585 + if ($this->getByYearDay()) { 586 + switch ($frequency) { 587 + case self::FREQUENCY_DAILY: 588 + case self::FREQUENCY_WEEKLY: 589 + case self::FREQUENCY_MONTHLY: 590 + // RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the 591 + // FREQ rule part is set to DAILY, WEEKLY, or MONTHLY." 592 + throw new Exception( 593 + pht( 594 + 'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '. 595 + 'MONTHLY, which violates RFC5545.')); 596 + default: 597 + break; 598 + } 599 + } 600 + 601 + // TODO 602 + // RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric 603 + // value when the FREQ rule part is not set to MONTHLY or YEARLY." 604 + // RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a 605 + // numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO 606 + // rule part is specified." 607 + 608 + 609 + $date = $this->getStartDateTime(); 610 + 611 + $this->cursorSecond = $date->getSecond(); 612 + $this->cursorMinute = $date->getMinute(); 613 + $this->cursorHour = $date->getHour(); 614 + 615 + $this->cursorDay = $date->getDay(); 616 + $this->cursorMonth = $date->getMonth(); 617 + $this->cursorYear = $date->getYear(); 618 + 619 + $year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart()); 620 + $key = $this->cursorMonth.'M'.$this->cursorDay.'D'; 621 + $this->cursorWeek = $year_map['info'][$key]['week']; 622 + $this->cursorWeekday = $year_map['info'][$key]['weekday']; 623 + 624 + $this->setSeconds = array(); 625 + $this->setMinutes = array(); 626 + $this->setHours = array(); 627 + $this->setDays = array(); 628 + $this->setMonths = array(); 629 + $this->setYears = array(); 630 + 631 + $this->stateSecond = null; 632 + $this->stateMinute = null; 633 + $this->stateHour = null; 634 + $this->stateDay = null; 635 + $this->stateWeek = null; 636 + $this->stateMonth = null; 637 + $this->stateYear = null; 638 + 639 + // If we have a BYSETPOS, we need to generate the entire set before we 640 + // can filter it and return results. Normally, we start generating at 641 + // the start date, but we need to go back one interval to generate 642 + // BYSETPOS events so we can make sure the entire set is generated. 643 + if ($this->getBySetPosition()) { 644 + $interval = $this->getInterval(); 645 + switch ($frequency) { 646 + case self::FREQUENCY_YEARLY: 647 + $this->cursorYear -= $interval; 648 + break; 649 + case self::FREQUENCY_MONTHLY: 650 + $this->cursorMonth -= $interval; 651 + $this->rewindMonth(); 652 + break; 653 + case self::FREQUENCY_WEEKLY: 654 + $this->cursorWeek -= $interval; 655 + $this->rewindWeek(); 656 + break; 657 + case self::FREQUENCY_DAILY: 658 + $this->cursorDay -= $interval; 659 + $this->rewindDay(); 660 + break; 661 + case self::FREQUENCY_HOURLY: 662 + $this->cursorHour -= $interval; 663 + $this->rewindHour(); 664 + break; 665 + case self::FREQUENCY_MINUTELY: 666 + $this->cursorMinute -= $interval; 667 + $this->rewindMinute(); 668 + break; 669 + case self::FREQUENCY_SECONDLY: 670 + default: 671 + throw new Exception( 672 + pht( 673 + 'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.', 674 + $frequency)); 675 + } 676 + } 677 + 678 + // We can generate events from before the cursor when evaluating rules 679 + // with BYSETPOS or FREQ=WEEKLY. 680 + $this->minimumEpoch = $this->getStartDateTime()->getEpoch(); 681 + 682 + $cursor_state = array( 683 + 'year' => $this->cursorYear, 684 + 'month' => $this->cursorMonth, 685 + 'week' => $this->cursorWeek, 686 + 'day' => $this->cursorDay, 687 + 'hour' => $this->cursorHour, 688 + ); 689 + 690 + $this->cursorDayState = $cursor_state; 691 + $this->cursorWeekState = $cursor_state; 692 + $this->cursorHourState = $cursor_state; 693 + 694 + $by_hour = $this->getByHour(); 695 + $by_minute = $this->getByMinute(); 696 + $by_second = $this->getBySecond(); 697 + 698 + $scale = $this->getFrequencyScale(); 699 + 700 + // We return all-day events if the start date is an all-day event and we 701 + // don't have more granular selectors or a more granular frequency. 702 + $this->isAllDay = $date->getIsAllDay() 703 + && !$by_hour 704 + && !$by_minute 705 + && !$by_second 706 + && ($scale > self::SCALE_HOURLY); 707 + } 708 + 709 + public function getNextEvent($cursor) { 710 + while (true) { 711 + $event = $this->generateNextEvent(); 712 + if (!$event) { 713 + break; 714 + } 715 + 716 + $epoch = $event->getEpoch(); 717 + if ($this->minimumEpoch) { 718 + if ($epoch < $this->minimumEpoch) { 719 + continue; 720 + } 721 + } 722 + 723 + if ($epoch < $cursor) { 724 + continue; 725 + } 726 + 727 + break; 728 + } 729 + 730 + return $event; 731 + } 732 + 733 + private function generateNextEvent() { 734 + if ($this->activeSet) { 735 + return array_pop($this->activeSet); 736 + } 737 + 738 + $this->baseYear = $this->cursorYear; 739 + 740 + $by_setpos = $this->getBySetPosition(); 741 + if ($by_setpos) { 742 + $old_state = $this->getSetPositionState(); 743 + } 744 + 745 + while (!$this->activeSet) { 746 + $this->activeSet = $this->nextSet; 747 + $this->nextSet = array(); 748 + 749 + while (true) { 750 + if ($this->isAllDay) { 751 + $this->nextDay(); 752 + } else { 753 + $this->nextSecond(); 754 + } 755 + 756 + $result = id(new PhutilCalendarAbsoluteDateTime()) 757 + ->setTimezone($this->getStartDateTime()->getTimezone()) 758 + ->setViewerTimezone($this->getViewerTimezone()) 759 + ->setYear($this->stateYear) 760 + ->setMonth($this->stateMonth) 761 + ->setDay($this->stateDay); 762 + 763 + if ($this->isAllDay) { 764 + $result->setIsAllDay(true); 765 + } else { 766 + $result 767 + ->setHour($this->stateHour) 768 + ->setMinute($this->stateMinute) 769 + ->setSecond($this->stateSecond); 770 + } 771 + 772 + // If we don't have BYSETPOS, we're all done. We put this into the 773 + // set and will immediately return it. 774 + if (!$by_setpos) { 775 + $this->activeSet[] = $result; 776 + break; 777 + } 778 + 779 + // Otherwise, check if we've completed a set. The set is complete if 780 + // the state has moved past the span we were examining (for example, 781 + // with a YEARLY event, if the state is now in the next year). 782 + $new_state = $this->getSetPositionState(); 783 + if ($new_state == $old_state) { 784 + $this->activeSet[] = $result; 785 + continue; 786 + } 787 + 788 + $this->activeSet = $this->applySetPos($this->activeSet, $by_setpos); 789 + $this->activeSet = array_reverse($this->activeSet); 790 + $this->nextSet[] = $result; 791 + $old_state = $new_state; 792 + break; 793 + } 794 + } 795 + 796 + return array_pop($this->activeSet); 797 + } 798 + 799 + 800 + protected function nextSecond() { 801 + if ($this->setSeconds) { 802 + $this->stateSecond = array_pop($this->setSeconds); 803 + return; 804 + } 805 + 806 + $frequency = $this->getFrequency(); 807 + $interval = $this->getInterval(); 808 + $is_secondly = ($frequency == self::FREQUENCY_SECONDLY); 809 + $by_second = $this->getBySecond(); 810 + 811 + while (!$this->setSeconds) { 812 + $this->nextMinute(); 813 + 814 + if ($is_secondly || $by_second) { 815 + $seconds = $this->newSecondsSet( 816 + ($is_secondly ? $interval : 1), 817 + $by_second); 818 + } else { 819 + $seconds = array( 820 + $this->cursorSecond, 821 + ); 822 + } 823 + 824 + $this->setSeconds = array_reverse($seconds); 825 + } 826 + 827 + $this->stateSecond = array_pop($this->setSeconds); 828 + } 829 + 830 + protected function nextMinute() { 831 + if ($this->setMinutes) { 832 + $this->stateMinute = array_pop($this->setMinutes); 833 + return; 834 + } 835 + 836 + $frequency = $this->getFrequency(); 837 + $interval = $this->getInterval(); 838 + $scale = $this->getFrequencyScale(); 839 + $is_minutely = ($frequency === self::FREQUENCY_MINUTELY); 840 + $by_minute = $this->getByMinute(); 841 + 842 + while (!$this->setMinutes) { 843 + $this->nextHour(); 844 + 845 + if ($is_minutely || $by_minute) { 846 + $minutes = $this->newMinutesSet( 847 + ($is_minutely ? $interval : 1), 848 + $by_minute); 849 + } else if ($scale < self::SCALE_MINUTELY) { 850 + $minutes = $this->newMinutesSet( 851 + 1, 852 + array()); 853 + } else { 854 + $minutes = array( 855 + $this->cursorMinute, 856 + ); 857 + } 858 + 859 + $this->setMinutes = array_reverse($minutes); 860 + } 861 + 862 + $this->stateMinute = array_pop($this->setMinutes); 863 + } 864 + 865 + protected function nextHour() { 866 + if ($this->setHours) { 867 + $this->stateHour = array_pop($this->setHours); 868 + return; 869 + } 870 + 871 + $frequency = $this->getFrequency(); 872 + $interval = $this->getInterval(); 873 + $scale = $this->getFrequencyScale(); 874 + $is_hourly = ($frequency === self::FREQUENCY_HOURLY); 875 + $by_hour = $this->getByHour(); 876 + 877 + while (!$this->setHours) { 878 + $this->nextDay(); 879 + 880 + $is_dynamic = $is_hourly 881 + || $by_hour 882 + || ($scale < self::SCALE_HOURLY); 883 + 884 + if ($is_dynamic) { 885 + $hours = $this->newHoursSet( 886 + ($is_hourly ? $interval : 1), 887 + $by_hour); 888 + } else { 889 + $hours = array( 890 + $this->cursorHour, 891 + ); 892 + } 893 + 894 + $this->setHours = array_reverse($hours); 895 + } 896 + 897 + $this->stateHour = array_pop($this->setHours); 898 + } 899 + 900 + protected function nextDay() { 901 + if ($this->setDays) { 902 + $info = array_pop($this->setDays); 903 + $this->setDayState($info); 904 + return; 905 + } 906 + 907 + $frequency = $this->getFrequency(); 908 + $interval = $this->getInterval(); 909 + $scale = $this->getFrequencyScale(); 910 + $is_daily = ($frequency === self::FREQUENCY_DAILY); 911 + $is_weekly = ($frequency === self::FREQUENCY_WEEKLY); 912 + 913 + $by_day = $this->getByDay(); 914 + $by_monthday = $this->getByMonthDay(); 915 + $by_yearday = $this->getByYearDay(); 916 + $by_weekno = $this->getByWeekNumber(); 917 + $by_month = $this->getByMonth(); 918 + $week_start = $this->getWeekStart(); 919 + 920 + while (!$this->setDays) { 921 + if ($is_weekly) { 922 + $this->nextWeek(); 923 + } else { 924 + $this->nextMonth(); 925 + } 926 + 927 + // NOTE: We normally handle BYMONTH when iterating months, but it acts 928 + // like a filter if FREQ=WEEKLY. 929 + 930 + $is_dynamic = $is_daily 931 + || $is_weekly 932 + || $by_day 933 + || $by_monthday 934 + || $by_yearday 935 + || $by_weekno 936 + || ($by_month && $is_weekly) 937 + || ($scale < self::SCALE_DAILY); 938 + 939 + if ($is_dynamic) { 940 + $weeks = $this->newDaysSet( 941 + ($is_daily ? $interval : 1), 942 + $by_day, 943 + $by_monthday, 944 + $by_yearday, 945 + $by_weekno, 946 + $by_month, 947 + $week_start); 948 + } else { 949 + // The cursor day may not actually exist in the current month, so 950 + // make sure the day is valid before we generate a set which contains 951 + // it. 952 + $year_map = $this->getYearMap($this->stateYear, $week_start); 953 + if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) { 954 + $weeks = array( 955 + array(), 956 + ); 957 + } else { 958 + $key = $this->stateMonth.'M'.$this->cursorDay.'D'; 959 + $weeks = array( 960 + array($year_map['info'][$key]), 961 + ); 962 + } 963 + } 964 + 965 + // Unpack the weeks into days. 966 + $days = array_mergev($weeks); 967 + 968 + $this->setDays = array_reverse($days); 969 + } 970 + 971 + $info = array_pop($this->setDays); 972 + $this->setDayState($info); 973 + } 974 + 975 + private function setDayState(array $info) { 976 + $this->stateDay = $info['monthday']; 977 + $this->stateWeek = $info['week']; 978 + $this->stateMonth = $info['month']; 979 + } 980 + 981 + protected function nextMonth() { 982 + if ($this->setMonths) { 983 + $this->stateMonth = array_pop($this->setMonths); 984 + return; 985 + } 986 + 987 + $frequency = $this->getFrequency(); 988 + $interval = $this->getInterval(); 989 + $scale = $this->getFrequencyScale(); 990 + $is_monthly = ($frequency === self::FREQUENCY_MONTHLY); 991 + 992 + $by_month = $this->getByMonth(); 993 + 994 + // If we have a BYMONTHDAY, we consider that set of days in every month. 995 + // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every 996 + // month", so we need to expand the month set if the constraint is present. 997 + $by_monthday = $this->getByMonthDay(); 998 + 999 + // Likewise, we need to generate all months if we have BYYEARDAY or 1000 + // BYWEEKNO or BYDAY. 1001 + $by_yearday = $this->getByYearDay(); 1002 + $by_weekno = $this->getByWeekNumber(); 1003 + $by_day = $this->getByDay(); 1004 + 1005 + while (!$this->setMonths) { 1006 + $this->nextYear(); 1007 + 1008 + $is_dynamic = $is_monthly 1009 + || $by_month 1010 + || $by_monthday 1011 + || $by_yearday 1012 + || $by_weekno 1013 + || $by_day 1014 + || ($scale < self::SCALE_MONTHLY); 1015 + 1016 + if ($is_dynamic) { 1017 + $months = $this->newMonthsSet( 1018 + ($is_monthly ? $interval : 1), 1019 + $by_month); 1020 + } else { 1021 + $months = array( 1022 + $this->cursorMonth, 1023 + ); 1024 + } 1025 + 1026 + $this->setMonths = array_reverse($months); 1027 + } 1028 + 1029 + $this->stateMonth = array_pop($this->setMonths); 1030 + } 1031 + 1032 + protected function nextWeek() { 1033 + if ($this->setWeeks) { 1034 + $this->stateWeek = array_pop($this->setWeeks); 1035 + return; 1036 + } 1037 + 1038 + $frequency = $this->getFrequency(); 1039 + $interval = $this->getInterval(); 1040 + $scale = $this->getFrequencyScale(); 1041 + $by_weekno = $this->getByWeekNumber(); 1042 + 1043 + while (!$this->setWeeks) { 1044 + $this->nextYear(); 1045 + 1046 + $weeks = $this->newWeeksSet( 1047 + $interval, 1048 + $by_weekno); 1049 + 1050 + $this->setWeeks = array_reverse($weeks); 1051 + } 1052 + 1053 + $this->stateWeek = array_pop($this->setWeeks); 1054 + } 1055 + 1056 + protected function nextYear() { 1057 + $this->stateYear = $this->cursorYear; 1058 + 1059 + $frequency = $this->getFrequency(); 1060 + $is_yearly = ($frequency === self::FREQUENCY_YEARLY); 1061 + 1062 + if ($is_yearly) { 1063 + $interval = $this->getInterval(); 1064 + } else { 1065 + $interval = 1; 1066 + } 1067 + 1068 + $this->cursorYear = $this->cursorYear + $interval; 1069 + 1070 + if ($this->cursorYear > ($this->baseYear + 100)) { 1071 + throw new Exception( 1072 + pht( 1073 + 'RRULE evaluation failed to generate more events in the next 100 '. 1074 + 'years. This RRULE is likely invalid or degenerate.')); 1075 + } 1076 + 1077 + } 1078 + 1079 + private function newSecondsSet($interval, $set) { 1080 + // TODO: This doesn't account for leap seconds. In theory, it probably 1081 + // should, although this shouldn't impact any real events. 1082 + $seconds_in_minute = 60; 1083 + 1084 + if ($this->cursorSecond >= $seconds_in_minute) { 1085 + $this->cursorSecond -= $seconds_in_minute; 1086 + return array(); 1087 + } 1088 + 1089 + list($cursor, $result) = $this->newIteratorSet( 1090 + $this->cursorSecond, 1091 + $interval, 1092 + $set, 1093 + $seconds_in_minute); 1094 + 1095 + $this->cursorSecond = ($cursor - $seconds_in_minute); 1096 + 1097 + return $result; 1098 + } 1099 + 1100 + private function newMinutesSet($interval, $set) { 1101 + // NOTE: This value is legitimately a constant! Amazing! 1102 + $minutes_in_hour = 60; 1103 + 1104 + if ($this->cursorMinute >= $minutes_in_hour) { 1105 + $this->cursorMinute -= $minutes_in_hour; 1106 + return array(); 1107 + } 1108 + 1109 + list($cursor, $result) = $this->newIteratorSet( 1110 + $this->cursorMinute, 1111 + $interval, 1112 + $set, 1113 + $minutes_in_hour); 1114 + 1115 + $this->cursorMinute = ($cursor - $minutes_in_hour); 1116 + 1117 + return $result; 1118 + } 1119 + 1120 + private function newHoursSet($interval, $set) { 1121 + // TODO: This doesn't account for hours caused by daylight savings time. 1122 + // It probably should, although this seems unlikely to impact any real 1123 + // events. 1124 + $hours_in_day = 24; 1125 + 1126 + // If the hour cursor is behind the current time, we need to forward it in 1127 + // INTERVAL increments so we end up with the right offset. 1128 + list($skip, $this->cursorHourState) = $this->advanceCursorState( 1129 + $this->cursorHourState, 1130 + self::SCALE_HOURLY, 1131 + $interval, 1132 + $this->getWeekStart()); 1133 + 1134 + if ($skip) { 1135 + return array(); 1136 + } 1137 + 1138 + list($cursor, $result) = $this->newIteratorSet( 1139 + $this->cursorHour, 1140 + $interval, 1141 + $set, 1142 + $hours_in_day); 1143 + 1144 + $this->cursorHour = ($cursor - $hours_in_day); 1145 + 1146 + return $result; 1147 + } 1148 + 1149 + private function newWeeksSet($interval, $set) { 1150 + $week_start = $this->getWeekStart(); 1151 + 1152 + list($skip, $this->cursorWeekState) = $this->advanceCursorState( 1153 + $this->cursorWeekState, 1154 + self::SCALE_WEEKLY, 1155 + $interval, 1156 + $week_start); 1157 + 1158 + if ($skip) { 1159 + return array(); 1160 + } 1161 + 1162 + $year_map = $this->getYearMap($this->stateYear, $week_start); 1163 + 1164 + $result = array(); 1165 + while (true) { 1166 + if (!isset($year_map['weekMap'][$this->cursorWeek])) { 1167 + break; 1168 + } 1169 + $result[] = $this->cursorWeek; 1170 + $this->cursorWeek += $interval; 1171 + } 1172 + 1173 + $this->cursorWeek -= $year_map['weekCount']; 1174 + 1175 + return $result; 1176 + } 1177 + 1178 + private function newDaysSet( 1179 + $interval_day, 1180 + $by_day, 1181 + $by_monthday, 1182 + $by_yearday, 1183 + $by_weekno, 1184 + $by_month, 1185 + $week_start) { 1186 + 1187 + $frequency = $this->getFrequency(); 1188 + $is_yearly = ($frequency == self::FREQUENCY_YEARLY); 1189 + $is_monthly = ($frequency == self::FREQUENCY_MONTHLY); 1190 + $is_weekly = ($frequency == self::FREQUENCY_WEEKLY); 1191 + 1192 + $selection = array(); 1193 + if ($is_weekly) { 1194 + $year_map = $this->getYearMap($this->stateYear, $week_start); 1195 + 1196 + if (isset($year_map['weekMap'][$this->stateWeek])) { 1197 + foreach ($year_map['weekMap'][$this->stateWeek] as $key) { 1198 + $selection[] = $year_map['info'][$key]; 1199 + } 1200 + } 1201 + } else { 1202 + // If the day cursor is behind the current year and month, we need to 1203 + // forward it in INTERVAL increments so we end up with the right offset 1204 + // in the current month. 1205 + list($skip, $this->cursorDayState) = $this->advanceCursorState( 1206 + $this->cursorDayState, 1207 + self::SCALE_DAILY, 1208 + $interval_day, 1209 + $week_start); 1210 + 1211 + if (!$skip) { 1212 + $year_map = $this->getYearMap($this->stateYear, $week_start); 1213 + while (true) { 1214 + $month_idx = $this->stateMonth; 1215 + $month_days = $year_map['monthDays'][$month_idx]; 1216 + if ($this->cursorDay > $month_days) { 1217 + // NOTE: The year map is now out of date, but we're about to break 1218 + // out of the loop anyway so it doesn't matter. 1219 + break; 1220 + } 1221 + 1222 + $day_idx = $this->cursorDay; 1223 + 1224 + $key = "{$month_idx}M{$day_idx}D"; 1225 + $selection[] = $year_map['info'][$key]; 1226 + 1227 + $this->cursorDay += $interval_day; 1228 + } 1229 + } 1230 + } 1231 + 1232 + // As a special case, BYDAY applies to relative month offsets if BYMONTH 1233 + // is present in a YEARLY rule. 1234 + if ($is_yearly) { 1235 + if ($this->getByMonth()) { 1236 + $is_yearly = false; 1237 + $is_monthly = true; 1238 + } 1239 + } 1240 + 1241 + // As a special case, BYDAY makes us examine all week days. This doesn't 1242 + // check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY. 1243 + $filter_weekday = true; 1244 + if ($is_weekly) { 1245 + if ($by_day) { 1246 + $filter_weekday = false; 1247 + } 1248 + } 1249 + 1250 + $weeks = array(); 1251 + foreach ($selection as $key => $info) { 1252 + if ($is_weekly) { 1253 + if ($filter_weekday) { 1254 + if ($info['weekday'] != $this->cursorWeekday) { 1255 + continue; 1256 + } 1257 + } 1258 + } else { 1259 + if ($info['month'] != $this->stateMonth) { 1260 + continue; 1261 + } 1262 + } 1263 + 1264 + if ($by_day) { 1265 + if (empty($by_day[$info['weekday']])) { 1266 + if ($is_yearly) { 1267 + if (empty($by_day[$info['weekday.yearly']]) && 1268 + empty($by_day[$info['-weekday.yearly']])) { 1269 + continue; 1270 + } 1271 + } else if ($is_monthly) { 1272 + if (empty($by_day[$info['weekday.monthly']]) && 1273 + empty($by_day[$info['-weekday.monthly']])) { 1274 + continue; 1275 + } 1276 + } else { 1277 + continue; 1278 + } 1279 + } 1280 + } 1281 + 1282 + if ($by_monthday) { 1283 + if (empty($by_monthday[$info['monthday']]) && 1284 + empty($by_monthday[$info['-monthday']])) { 1285 + continue; 1286 + } 1287 + } 1288 + 1289 + if ($by_yearday) { 1290 + if (empty($by_yearday[$info['yearday']]) && 1291 + empty($by_yearday[$info['-yearday']])) { 1292 + continue; 1293 + } 1294 + } 1295 + 1296 + if ($by_weekno) { 1297 + if (empty($by_weekno[$info['week']]) && 1298 + empty($by_weekno[$info['-week']])) { 1299 + continue; 1300 + } 1301 + } 1302 + 1303 + if ($by_month) { 1304 + if (empty($by_month[$info['month']])) { 1305 + continue; 1306 + } 1307 + } 1308 + 1309 + $weeks[$info['week']][] = $info; 1310 + } 1311 + 1312 + return array_values($weeks); 1313 + } 1314 + 1315 + private function newMonthsSet($interval, $set) { 1316 + // NOTE: This value is also a real constant! Wow! 1317 + $months_in_year = 12; 1318 + 1319 + if ($this->cursorMonth > $months_in_year) { 1320 + $this->cursorMonth -= $months_in_year; 1321 + return array(); 1322 + } 1323 + 1324 + list($cursor, $result) = $this->newIteratorSet( 1325 + $this->cursorMonth, 1326 + $interval, 1327 + $set, 1328 + $months_in_year + 1); 1329 + 1330 + $this->cursorMonth = ($cursor - $months_in_year); 1331 + 1332 + return $result; 1333 + } 1334 + 1335 + public static function getYearMap($year, $week_start) { 1336 + static $maps = array(); 1337 + 1338 + $key = "{$year}/{$week_start}"; 1339 + if (isset($maps[$key])) { 1340 + return $maps[$key]; 1341 + } 1342 + 1343 + $map = self::newYearMap($year, $week_start); 1344 + $maps[$key] = $map; 1345 + 1346 + return $maps[$key]; 1347 + } 1348 + 1349 + private static function newYearMap($year, $weekday_start) { 1350 + $weekday_index = self::getWeekdayIndex($weekday_start); 1351 + 1352 + $is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) || 1353 + ($year % 400 === 0); 1354 + 1355 + // There may be some clever way to figure out which day of the week a given 1356 + // year starts on and avoid the cost of a DateTime construction, but I 1357 + // wasn't able to turn it up and we only need to do this once per year. 1358 + $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC')); 1359 + $weekday = (int)$datetime->format('w'); 1360 + 1361 + if ($is_leap) { 1362 + $max_day = 366; 1363 + } else { 1364 + $max_day = 365; 1365 + } 1366 + 1367 + $month_days = array( 1368 + 1 => 31, 1369 + 2 => $is_leap ? 29 : 28, 1370 + 3 => 31, 1371 + 4 => 30, 1372 + 5 => 31, 1373 + 6 => 30, 1374 + 7 => 31, 1375 + 8 => 31, 1376 + 9 => 30, 1377 + 10 => 31, 1378 + 11 => 30, 1379 + 12 => 31, 1380 + ); 1381 + 1382 + // Per the spec, the first week of the year must contain at least four 1383 + // days. If the week starts on a Monday but the year starts on a Saturday, 1384 + // the first couple of days don't count as a week. In this case, the first 1385 + // week will begin on January 3. 1386 + $first_week_size = 0; 1387 + $first_weekday = $weekday; 1388 + for ($year_day = 1; $year_day <= $max_day; $year_day++) { 1389 + $first_weekday = ($first_weekday + 1) % 7; 1390 + $first_week_size++; 1391 + if ($first_weekday === $weekday_index) { 1392 + break; 1393 + } 1394 + } 1395 + 1396 + if ($first_week_size >= 4) { 1397 + $week_number = 1; 1398 + } else { 1399 + $week_number = 0; 1400 + } 1401 + 1402 + $info_map = array(); 1403 + 1404 + $weekday_map = self::getWeekdayIndexMap(); 1405 + $weekday_map = array_flip($weekday_map); 1406 + 1407 + $yearly_counts = array(); 1408 + $monthly_counts = array(); 1409 + 1410 + $month_number = 1; 1411 + $month_day = 1; 1412 + for ($year_day = 1; $year_day <= $max_day; $year_day++) { 1413 + $key = "{$month_number}M{$month_day}D"; 1414 + 1415 + $short_day = $weekday_map[$weekday]; 1416 + if (empty($yearly_counts[$short_day])) { 1417 + $yearly_counts[$short_day] = 0; 1418 + } 1419 + $yearly_counts[$short_day]++; 1420 + 1421 + if (empty($monthly_counts[$month_number][$short_day])) { 1422 + $monthly_counts[$month_number][$short_day] = 0; 1423 + } 1424 + $monthly_counts[$month_number][$short_day]++; 1425 + 1426 + $info = array( 1427 + 'year' => $year, 1428 + 'key' => $key, 1429 + 'month' => $month_number, 1430 + 'monthday' => $month_day, 1431 + '-monthday' => -$month_days[$month_number] + $month_day - 1, 1432 + 'yearday' => $year_day, 1433 + '-yearday' => -$max_day + $year_day - 1, 1434 + 'week' => $week_number, 1435 + 'weekday' => $short_day, 1436 + 'weekday.yearly' => $yearly_counts[$short_day], 1437 + 'weekday.monthly' => $monthly_counts[$month_number][$short_day], 1438 + ); 1439 + 1440 + $info_map[$key] = $info; 1441 + 1442 + $weekday = ($weekday + 1) % 7; 1443 + if ($weekday === $weekday_index) { 1444 + $week_number++; 1445 + } 1446 + 1447 + $month_day = ($month_day + 1); 1448 + if ($month_day > $month_days[$month_number]) { 1449 + $month_day = 1; 1450 + $month_number++; 1451 + } 1452 + } 1453 + 1454 + // Check how long the final week is. If it doesn't have four days, this 1455 + // is really the first week of the next year. 1456 + $final_week = array(); 1457 + foreach ($info_map as $key => $info) { 1458 + if ($info['week'] == $week_number) { 1459 + $final_week[] = $key; 1460 + } 1461 + } 1462 + 1463 + if (count($final_week) < 4) { 1464 + $week_number = $week_number - 1; 1465 + $next_year = self::getYearMap($year + 1, $weekday_start); 1466 + $next_year_weeks = $next_year['weekCount']; 1467 + } else { 1468 + $next_year_weeks = null; 1469 + } 1470 + 1471 + if ($first_week_size < 4) { 1472 + $last_year = self::getYearMap($year - 1, $weekday_start); 1473 + $last_year_weeks = $last_year['weekCount']; 1474 + } else { 1475 + $last_year_weeks = null; 1476 + } 1477 + 1478 + // Now that we know how many weeks the year has, we can compute the 1479 + // negative offsets. 1480 + foreach ($info_map as $key => $info) { 1481 + $week = $info['week']; 1482 + 1483 + if ($week === 0) { 1484 + // If this day is part of the first partial week of the year, give 1485 + // it the week number of the last week of the prior year instead. 1486 + $info['week'] = $last_year_weeks; 1487 + $info['-week'] = -1; 1488 + } else if ($week > $week_number) { 1489 + // If this day is part of the last partial week of the year, give 1490 + // it week numbers from the next year. 1491 + $info['week'] = 1; 1492 + $info['-week'] = -$next_year_weeks; 1493 + } else { 1494 + $info['-week'] = -$week_number + $week - 1; 1495 + } 1496 + 1497 + // Do all the arithmetic to figure out if this is the -19th Thursday 1498 + // in the year and such. 1499 + $month_number = $info['month']; 1500 + $short_day = $info['weekday']; 1501 + $monthly_count = $monthly_counts[$month_number][$short_day]; 1502 + $monthly_index = $info['weekday.monthly']; 1503 + $info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1; 1504 + $info['-weekday.monthly'] .= $short_day; 1505 + $info['weekday.monthly'] .= $short_day; 1506 + 1507 + $yearly_count = $yearly_counts[$short_day]; 1508 + $yearly_index = $info['weekday.yearly']; 1509 + $info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1; 1510 + $info['-weekday.yearly'] .= $short_day; 1511 + $info['weekday.yearly'] .= $short_day; 1512 + 1513 + $info_map[$key] = $info; 1514 + } 1515 + 1516 + $week_map = array(); 1517 + foreach ($info_map as $key => $info) { 1518 + $week_map[$info['week']][] = $key; 1519 + } 1520 + 1521 + return array( 1522 + 'info' => $info_map, 1523 + 'weekCount' => $week_number, 1524 + 'dayCount' => $max_day, 1525 + 'monthDays' => $month_days, 1526 + 'weekMap' => $week_map, 1527 + ); 1528 + } 1529 + 1530 + private function newIteratorSet($cursor, $interval, $set, $limit) { 1531 + if ($interval < 1) { 1532 + throw new Exception( 1533 + pht( 1534 + 'Invalid iteration interval ("%d"), must be at least 1.', 1535 + $interval)); 1536 + } 1537 + 1538 + $result = array(); 1539 + $seen = array(); 1540 + 1541 + $ii = $cursor; 1542 + while (true) { 1543 + if (!$set || isset($set[$ii])) { 1544 + $result[] = $ii; 1545 + } 1546 + 1547 + $ii = ($ii + $interval); 1548 + 1549 + if ($ii >= $limit) { 1550 + break; 1551 + } 1552 + } 1553 + 1554 + sort($result); 1555 + $result = array_values($result); 1556 + 1557 + return array($ii, $result); 1558 + } 1559 + 1560 + private function applySetPos(array $values, array $setpos) { 1561 + $select = array(); 1562 + 1563 + $count = count($values); 1564 + foreach ($setpos as $pos) { 1565 + if ($pos > 0 && $pos <= $count) { 1566 + $select[] = ($pos - 1); 1567 + } else if ($pos < 0 && $pos >= -$count) { 1568 + $select[] = ($count + $pos); 1569 + } 1570 + } 1571 + 1572 + sort($select); 1573 + $select = array_unique($select); 1574 + 1575 + return array_select_keys($values, $select); 1576 + } 1577 + 1578 + private function assertByRange( 1579 + $source, 1580 + array $values, 1581 + $min, 1582 + $max, 1583 + $allow_zero = true) { 1584 + 1585 + foreach ($values as $value) { 1586 + if (!is_int($value)) { 1587 + throw new Exception( 1588 + pht( 1589 + 'Value "%s" in RRULE "%s" parameter is invalid: values must be '. 1590 + 'integers.', 1591 + $value, 1592 + $source)); 1593 + } 1594 + 1595 + if ($value < $min || $value > $max) { 1596 + throw new Exception( 1597 + pht( 1598 + 'Value "%s" in RRULE "%s" parameter is invalid: it must be '. 1599 + 'between %s and %s.', 1600 + $value, 1601 + $source, 1602 + $min, 1603 + $max)); 1604 + } 1605 + 1606 + if (!$value && !$allow_zero) { 1607 + throw new Exception( 1608 + pht( 1609 + 'Value "%s" in RRULE "%s" parameter is invalid: it must not '. 1610 + 'be zero.', 1611 + $value, 1612 + $source)); 1613 + } 1614 + } 1615 + } 1616 + 1617 + private function getSetPositionState() { 1618 + $scale = $this->getFrequencyScale(); 1619 + 1620 + $parts = array(); 1621 + $parts[] = $this->stateYear; 1622 + 1623 + if ($scale == self::SCALE_WEEKLY) { 1624 + $parts[] = $this->stateWeek; 1625 + } else { 1626 + if ($scale < self::SCALE_YEARLY) { 1627 + $parts[] = $this->stateMonth; 1628 + } 1629 + if ($scale < self::SCALE_MONTHLY) { 1630 + $parts[] = $this->stateDay; 1631 + } 1632 + if ($scale < self::SCALE_DAILY) { 1633 + $parts[] = $this->stateHour; 1634 + } 1635 + if ($scale < self::SCALE_HOURLY) { 1636 + $parts[] = $this->stateMinute; 1637 + } 1638 + } 1639 + 1640 + return implode('/', $parts); 1641 + } 1642 + 1643 + private function rewindMonth() { 1644 + while ($this->cursorMonth < 1) { 1645 + $this->cursorYear--; 1646 + $this->cursorMonth += 12; 1647 + } 1648 + } 1649 + 1650 + private function rewindWeek() { 1651 + $week_start = $this->getWeekStart(); 1652 + while ($this->cursorWeek < 1) { 1653 + $this->cursorYear--; 1654 + $year_map = $this->getYearMap($this->cursorYear, $week_start); 1655 + $this->cursorWeek += $year_map['weekCount']; 1656 + } 1657 + } 1658 + 1659 + private function rewindDay() { 1660 + $week_start = $this->getWeekStart(); 1661 + while ($this->cursorDay < 1) { 1662 + $year_map = $this->getYearMap($this->cursorYear, $week_start); 1663 + $this->cursorDay += $year_map['monthDays'][$this->cursorMonth]; 1664 + $this->cursorMonth--; 1665 + $this->rewindMonth(); 1666 + } 1667 + } 1668 + 1669 + private function rewindHour() { 1670 + while ($this->cursorHour < 0) { 1671 + $this->cursorHour += 24; 1672 + $this->cursorDay--; 1673 + $this->rewindDay(); 1674 + } 1675 + } 1676 + 1677 + private function rewindMinute() { 1678 + while ($this->cursorMinute < 0) { 1679 + $this->cursorMinute += 60; 1680 + $this->cursorHour--; 1681 + $this->rewindHour(); 1682 + } 1683 + } 1684 + 1685 + private function advanceCursorState( 1686 + array $cursor, 1687 + $scale, 1688 + $interval, 1689 + $week_start) { 1690 + 1691 + $state = array( 1692 + 'year' => $this->stateYear, 1693 + 'month' => $this->stateMonth, 1694 + 'week' => $this->stateWeek, 1695 + 'day' => $this->stateDay, 1696 + 'hour' => $this->stateHour, 1697 + ); 1698 + 1699 + // In the common case when the interval is 1, we'll visit every possible 1700 + // value so we don't need to do any math and can just jump to the first 1701 + // hour, day, etc. 1702 + if ($interval == 1) { 1703 + if ($this->isCursorBehind($cursor, $state, $scale)) { 1704 + switch ($scale) { 1705 + case self::SCALE_DAILY: 1706 + $this->cursorDay = 1; 1707 + break; 1708 + case self::SCALE_HOURLY: 1709 + $this->cursorHour = 0; 1710 + break; 1711 + case self::SCALE_WEEKLY: 1712 + $this->cursorWeek = 1; 1713 + break; 1714 + } 1715 + } 1716 + 1717 + return array(false, $state); 1718 + } 1719 + 1720 + $year_map = $this->getYearMap($cursor['year'], $week_start); 1721 + while ($this->isCursorBehind($cursor, $state, $scale)) { 1722 + switch ($scale) { 1723 + case self::SCALE_DAILY: 1724 + $cursor['day'] += $interval; 1725 + break; 1726 + case self::SCALE_HOURLY: 1727 + $cursor['hour'] += $interval; 1728 + break; 1729 + case self::SCALE_WEEKLY: 1730 + $cursor['week'] += $interval; 1731 + break; 1732 + } 1733 + 1734 + if ($scale <= self::SCALE_HOURLY) { 1735 + while ($cursor['hour'] >= 24) { 1736 + $cursor['hour'] -= 24; 1737 + $cursor['day']++; 1738 + } 1739 + } 1740 + 1741 + if ($scale == self::SCALE_WEEKLY) { 1742 + while ($cursor['week'] > $year_map['weekCount']) { 1743 + $cursor['week'] -= $year_map['weekCount']; 1744 + $cursor['year']++; 1745 + $year_map = $this->getYearMap($cursor['year'], $week_start); 1746 + } 1747 + } 1748 + 1749 + if ($scale <= self::SCALE_DAILY) { 1750 + while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) { 1751 + $cursor['day'] -= $year_map['monthDays'][$cursor['month']]; 1752 + $cursor['month']++; 1753 + if ($cursor['month'] > 12) { 1754 + $cursor['month'] -= 12; 1755 + $cursor['year']++; 1756 + $year_map = $this->getYearMap($cursor['year'], $week_start); 1757 + } 1758 + } 1759 + } 1760 + } 1761 + 1762 + switch ($scale) { 1763 + case self::SCALE_DAILY: 1764 + $this->cursorDay = $cursor['day']; 1765 + break; 1766 + case self::SCALE_HOURLY: 1767 + $this->cursorHour = $cursor['hour']; 1768 + break; 1769 + case self::SCALE_WEEKLY: 1770 + $this->cursorWeek = $cursor['week']; 1771 + break; 1772 + } 1773 + 1774 + $skip = $this->isCursorBehind($state, $cursor, $scale); 1775 + 1776 + return array($skip, $cursor); 1777 + } 1778 + 1779 + private function isCursorBehind(array $cursor, array $state, $scale) { 1780 + if ($cursor['year'] < $state['year']) { 1781 + return true; 1782 + } else if ($cursor['year'] > $state['year']) { 1783 + return false; 1784 + } 1785 + 1786 + if ($scale == self::SCALE_WEEKLY) { 1787 + return false; 1788 + } 1789 + 1790 + if ($cursor['month'] < $state['month']) { 1791 + return true; 1792 + } else if ($cursor['month'] > $state['month']) { 1793 + return false; 1794 + } 1795 + 1796 + if ($scale >= self::SCALE_DAILY) { 1797 + return false; 1798 + } 1799 + 1800 + if ($cursor['day'] < $state['day']) { 1801 + return true; 1802 + } else if ($cursor['day'] > $state['day']) { 1803 + return false; 1804 + } 1805 + 1806 + if ($scale >= self::SCALE_HOURLY) { 1807 + return false; 1808 + } 1809 + 1810 + if ($cursor['hour'] < $state['hour']) { 1811 + return true; 1812 + } else if ($cursor['hour'] > $state['hour']) { 1813 + return false; 1814 + } 1815 + 1816 + return false; 1817 + } 1818 + 1819 + 1820 + }
+162
src/applications/calendar/parser/data/PhutilCalendarRecurrenceSet.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRecurrenceSet 4 + extends Phobject { 5 + 6 + private $sources = array(); 7 + private $viewerTimezone = 'UTC'; 8 + 9 + public function addSource(PhutilCalendarRecurrenceSource $source) { 10 + $this->sources[] = $source; 11 + return $this; 12 + } 13 + 14 + public function setViewerTimezone($viewer_timezone) { 15 + $this->viewerTimezone = $viewer_timezone; 16 + return $this; 17 + } 18 + 19 + public function getViewerTimezone() { 20 + return $this->viewerTimezone; 21 + } 22 + 23 + public function getEventsBetween( 24 + PhutilCalendarDateTime $start = null, 25 + PhutilCalendarDateTime $end = null, 26 + $limit = null) { 27 + 28 + if ($end === null && $limit === null) { 29 + throw new Exception( 30 + pht( 31 + 'Recurring event range queries must have an end date, a limit, or '. 32 + 'both.')); 33 + } 34 + 35 + $timezone = $this->getViewerTimezone(); 36 + 37 + $sources = array(); 38 + foreach ($this->sources as $source) { 39 + $source = clone $source; 40 + $source->setViewerTimezone($timezone); 41 + $source->resetSource(); 42 + 43 + $sources[] = array( 44 + 'source' => $source, 45 + 'state' => null, 46 + 'epoch' => null, 47 + ); 48 + } 49 + 50 + if ($start) { 51 + $start = clone $start; 52 + $start->setViewerTimezone($timezone); 53 + $min_epoch = $start->getEpoch(); 54 + } else { 55 + $min_epoch = 0; 56 + } 57 + 58 + if ($end) { 59 + $end = clone $end; 60 + $end->setViewerTimezone($timezone); 61 + $end_epoch = $end->getEpoch(); 62 + } else { 63 + $end_epoch = null; 64 + } 65 + 66 + $results = array(); 67 + $index = 0; 68 + $cursor = 0; 69 + while (true) { 70 + // Get the next event for each source which we don't have a future 71 + // event for. 72 + foreach ($sources as $key => $source) { 73 + $state = $source['state']; 74 + $epoch = $source['epoch']; 75 + 76 + if ($state !== null && $epoch >= $cursor) { 77 + // We have an event for this source, and it's a future event, so 78 + // we don't need to do anything. 79 + continue; 80 + } 81 + 82 + $next = $source['source']->getNextEvent($cursor); 83 + if ($next === null) { 84 + // This source doesn't have any more events, so we're all done. 85 + unset($sources[$key]); 86 + continue; 87 + } 88 + 89 + $next_epoch = $next->getEpoch(); 90 + 91 + if ($end_epoch !== null && $next_epoch > $end_epoch) { 92 + // We have an end time and the next event from this source is 93 + // past that end, so we know there are no more relevant events 94 + // coming from this source. 95 + unset($sources[$key]); 96 + continue; 97 + } 98 + 99 + $sources[$key]['state'] = $next; 100 + $sources[$key]['epoch'] = $next_epoch; 101 + } 102 + 103 + if (!$sources) { 104 + // We've run out of sources which can produce valid events in the 105 + // window, so we're all done. 106 + break; 107 + } 108 + 109 + // Find the minimum event time across all sources. 110 + $next_epoch = null; 111 + foreach ($sources as $source) { 112 + if ($next_epoch === null) { 113 + $next_epoch = $source['epoch']; 114 + } else { 115 + $next_epoch = min($next_epoch, $source['epoch']); 116 + } 117 + } 118 + 119 + $is_exception = false; 120 + $next_source = null; 121 + foreach ($sources as $source) { 122 + if ($source['epoch'] == $next_epoch) { 123 + if ($source['source']->getIsExceptionSource()) { 124 + $is_exception = true; 125 + } else { 126 + $next_source = $source; 127 + } 128 + } 129 + } 130 + 131 + // If this is an exception, it means the event does NOT occur. We 132 + // skip it and move on. If it's not an exception, it does occur, so 133 + // we record it. 134 + if (!$is_exception) { 135 + 136 + // Only actually include this event in the results if it starts after 137 + // any specified start time. We increment the index regardless, so we 138 + // return results with proper offsets. 139 + if ($next_source['epoch'] >= $min_epoch) { 140 + $results[$index] = $next_source['state']; 141 + } 142 + $index++; 143 + 144 + if ($limit !== null && (count($results) >= $limit)) { 145 + break; 146 + } 147 + } 148 + 149 + $cursor = $next_epoch + 1; 150 + 151 + // If we have an end of the window and we've reached it, we're done. 152 + if ($end_epoch) { 153 + if ($cursor > $end_epoch) { 154 + break; 155 + } 156 + } 157 + } 158 + 159 + return $results; 160 + } 161 + 162 + }
+34
src/applications/calendar/parser/data/PhutilCalendarRecurrenceSource.php
··· 1 + <?php 2 + 3 + abstract class PhutilCalendarRecurrenceSource 4 + extends Phobject { 5 + 6 + private $isExceptionSource; 7 + private $viewerTimezone; 8 + 9 + public function setIsExceptionSource($is_exception_source) { 10 + $this->isExceptionSource = $is_exception_source; 11 + return $this; 12 + } 13 + 14 + public function getIsExceptionSource() { 15 + return $this->isExceptionSource; 16 + } 17 + 18 + public function setViewerTimezone($viewer_timezone) { 19 + $this->viewerTimezone = $viewer_timezone; 20 + return $this; 21 + } 22 + 23 + public function getViewerTimezone() { 24 + return $this->viewerTimezone; 25 + } 26 + 27 + public function resetSource() { 28 + return; 29 + } 30 + 31 + abstract public function getNextEvent($cursor); 32 + 33 + 34 + }
+74
src/applications/calendar/parser/data/PhutilCalendarRelativeDateTime.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRelativeDateTime 4 + extends PhutilCalendarProxyDateTime { 5 + 6 + private $duration; 7 + 8 + public function setOrigin(PhutilCalendarDateTime $origin) { 9 + return $this->setProxy($origin); 10 + } 11 + 12 + public function getOrigin() { 13 + return $this->getProxy(); 14 + } 15 + 16 + public function setDuration(PhutilCalendarDuration $duration) { 17 + $this->duration = $duration; 18 + return $this; 19 + } 20 + 21 + public function getDuration() { 22 + return $this->duration; 23 + } 24 + 25 + public function newPHPDateTime() { 26 + $datetime = parent::newPHPDateTime(); 27 + $duration = $this->getDuration(); 28 + 29 + if ($duration->getIsNegative()) { 30 + $sign = '-'; 31 + } else { 32 + $sign = '+'; 33 + } 34 + 35 + $map = array( 36 + 'weeks' => $duration->getWeeks(), 37 + 'days' => $duration->getDays(), 38 + 'hours' => $duration->getHours(), 39 + 'minutes' => $duration->getMinutes(), 40 + 'seconds' => $duration->getSeconds(), 41 + ); 42 + 43 + foreach ($map as $unit => $value) { 44 + if (!$value) { 45 + continue; 46 + } 47 + $datetime->modify("{$sign}{$value} {$unit}"); 48 + } 49 + 50 + return $datetime; 51 + } 52 + 53 + public function newAbsoluteDateTime() { 54 + $clone = clone $this; 55 + 56 + if ($clone->getTimezone()) { 57 + $clone->setViewerTimezone(null); 58 + } 59 + 60 + $datetime = $clone->newPHPDateTime(); 61 + 62 + return id(new PhutilCalendarAbsoluteDateTime()) 63 + ->setYear((int)$datetime->format('Y')) 64 + ->setMonth((int)$datetime->format('m')) 65 + ->setDay((int)$datetime->format('d')) 66 + ->setHour((int)$datetime->format('H')) 67 + ->setMinute((int)$datetime->format('i')) 68 + ->setSecond((int)$datetime->format('s')) 69 + ->setIsAllDay($clone->getIsAllDay()) 70 + ->setTimezone($clone->getTimezone()) 71 + ->setViewerTimezone($this->getViewerTimezone()); 72 + } 73 + 74 + }
+12
src/applications/calendar/parser/data/PhutilCalendarRootNode.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRootNode 4 + extends PhutilCalendarContainerNode { 5 + 6 + const NODETYPE = 'root'; 7 + 8 + public function getDocuments() { 9 + return $this->getChildrenOfType(PhutilCalendarDocumentNode::NODETYPE); 10 + } 11 + 12 + }
+40
src/applications/calendar/parser/data/PhutilCalendarUserNode.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarUserNode extends PhutilCalendarNode { 4 + 5 + private $name; 6 + private $uri; 7 + private $status; 8 + 9 + const STATUS_INVITED = 'invited'; 10 + const STATUS_ACCEPTED = 'accepted'; 11 + const STATUS_DECLINED = 'declined'; 12 + 13 + public function setName($name) { 14 + $this->name = $name; 15 + return $this; 16 + } 17 + 18 + public function getName() { 19 + return $this->name; 20 + } 21 + 22 + public function setURI($uri) { 23 + $this->uri = $uri; 24 + return $this; 25 + } 26 + 27 + public function getURI() { 28 + return $this->uri; 29 + } 30 + 31 + public function setStatus($status) { 32 + $this->status = $status; 33 + return $this; 34 + } 35 + 36 + public function getStatus() { 37 + return $this->status; 38 + } 39 + 40 + }
+49
src/applications/calendar/parser/data/__tests__/PhutilCalendarDateTimeTestCase.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarDateTimeTestCase extends PhutilTestCase { 4 + 5 + public function testDateTimeDuration() { 6 + $start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20161128T090000Z') 7 + ->setTimezone('America/Los_Angeles') 8 + ->setViewerTimezone('America/Chicago') 9 + ->setIsAllDay(true); 10 + 11 + $this->assertEqual( 12 + '20161128', 13 + $start->getISO8601()); 14 + 15 + $end = $start 16 + ->newAbsoluteDateTime() 17 + ->setHour(0) 18 + ->setMinute(0) 19 + ->setSecond(0) 20 + ->newRelativeDateTime('P1D') 21 + ->newAbsoluteDateTime(); 22 + 23 + $this->assertEqual( 24 + '20161129', 25 + $end->getISO8601()); 26 + 27 + // This is a date which explicitly has no specified timezone. 28 + $start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20161128', null) 29 + ->setViewerTimezone('UTC'); 30 + 31 + $this->assertEqual( 32 + '20161128', 33 + $start->getISO8601()); 34 + 35 + $end = $start 36 + ->newAbsoluteDateTime() 37 + ->setHour(0) 38 + ->setMinute(0) 39 + ->setSecond(0) 40 + ->newRelativeDateTime('P1D') 41 + ->newAbsoluteDateTime(); 42 + 43 + $this->assertEqual( 44 + '20161129', 45 + $end->getISO8601()); 46 + } 47 + 48 + 49 + }
+1750
src/applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRecurrenceRuleTestCase extends PhutilTestCase { 4 + 5 + public function testSimpleRecurrenceRules() { 6 + $start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'); 7 + 8 + $rrule = id(new PhutilCalendarRecurrenceRule()) 9 + ->setStartDateTime($start) 10 + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_DAILY); 11 + 12 + $set = id(new PhutilCalendarRecurrenceSet()) 13 + ->addSource($rrule); 14 + 15 + $result = $set->getEventsBetween(null, null, 3); 16 + 17 + $expect = array( 18 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 19 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 20 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 21 + ); 22 + 23 + $this->assertEqual( 24 + mpull($expect, 'getISO8601'), 25 + mpull($result, 'getISO8601'), 26 + pht('Simple daily event.')); 27 + 28 + 29 + 30 + $rrule = id(new PhutilCalendarRecurrenceRule()) 31 + ->setStartDateTime($start) 32 + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_HOURLY) 33 + ->setByHour(array(12, 13)); 34 + 35 + $set = id(new PhutilCalendarRecurrenceSet()) 36 + ->addSource($rrule); 37 + 38 + $result = $set->getEventsBetween(null, null, 5); 39 + 40 + $expect = array( 41 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 42 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T130000Z'), 43 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 44 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T130000Z'), 45 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 46 + ); 47 + 48 + $this->assertEqual( 49 + mpull($expect, 'getISO8601'), 50 + mpull($result, 'getISO8601'), 51 + pht('Hourly event with BYHOUR.')); 52 + 53 + 54 + $rrule = id(new PhutilCalendarRecurrenceRule()) 55 + ->setStartDateTime($start) 56 + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY); 57 + 58 + $set = id(new PhutilCalendarRecurrenceSet()) 59 + ->addSource($rrule); 60 + 61 + $result = $set->getEventsBetween(null, null, 2); 62 + 63 + $expect = array( 64 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 65 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'), 66 + ); 67 + 68 + $this->assertEqual( 69 + mpull($expect, 'getISO8601'), 70 + mpull($result, 'getISO8601'), 71 + pht('Yearly event.')); 72 + 73 + 74 + // This is an efficiency test for bizarre rules: it defines a secondly 75 + // event which only occurs one a year, and generates 3 instances of it. 76 + // This implementation should be fast enough that this test doesn't take 77 + // a significant amount of time. 78 + 79 + $rrule = id(new PhutilCalendarRecurrenceRule()) 80 + ->setStartDateTime($start) 81 + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_SECONDLY) 82 + ->setByMonth(array(1)) 83 + ->setByMonthDay(array(1)) 84 + ->setByHour(array(12)) 85 + ->setByMinute(array(0)) 86 + ->setBySecond(array(0)); 87 + 88 + $set = id(new PhutilCalendarRecurrenceSet()) 89 + ->addSource($rrule); 90 + 91 + $result = $set->getEventsBetween(null, null, 3); 92 + 93 + $expect = array( 94 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 95 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'), 96 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20180101T120000Z'), 97 + ); 98 + 99 + $this->assertEqual( 100 + mpull($expect, 'getISO8601'), 101 + mpull($result, 'getISO8601'), 102 + pht('Secondly event with many constraints.')); 103 + } 104 + 105 + public function testYearlyRecurrenceRules() { 106 + $tests = array(); 107 + $expect = array(); 108 + 109 + $tests[] = array(); 110 + $expect[] = array( 111 + '19970902', 112 + '19980902', 113 + '19990902', 114 + ); 115 + 116 + $tests[] = array( 117 + 'INTERVAL' => 2, 118 + ); 119 + $expect[] = array( 120 + '19970902', 121 + '19990902', 122 + '20010902', 123 + ); 124 + 125 + $tests[] = array( 126 + 'DTSTART' => '20000229', 127 + ); 128 + $expect[] = array( 129 + '20000229', 130 + '20040229', 131 + '20080229', 132 + ); 133 + 134 + $tests[] = array( 135 + 'BYMONTH' => array(1, 3), 136 + ); 137 + $expect[] = array( 138 + '19980102', 139 + '19980302', 140 + '19990102', 141 + ); 142 + 143 + $tests[] = array( 144 + 'BYMONTHDAY' => array(1, 3), 145 + ); 146 + $expect[] = array( 147 + '19970903', 148 + '19971001', 149 + '19971003', 150 + ); 151 + 152 + $tests[] = array( 153 + 'BYMONTH' => array(1, 3), 154 + 'BYMONTHDAY' => array(5, 7), 155 + ); 156 + $expect[] = array( 157 + '19980105', 158 + '19980107', 159 + '19980305', 160 + ); 161 + 162 + $tests[] = array( 163 + 'BYDAY' => array('TU', 'TH'), 164 + ); 165 + $expect[] = array( 166 + '19970902', 167 + '19970904', 168 + '19970909', 169 + ); 170 + 171 + $tests[] = array( 172 + 'BYDAY' => array('SU'), 173 + ); 174 + $expect[] = array( 175 + '19970907', 176 + '19970914', 177 + '19970921', 178 + ); 179 + 180 + $tests[] = array( 181 + 'BYMONTH' => array(1, 3), 182 + 'BYDAY' => array('TU', 'TH'), 183 + ); 184 + $expect[] = array( 185 + '19980101', 186 + '19980106', 187 + '19980108', 188 + ); 189 + 190 + $tests[] = array( 191 + 'BYMONTHDAY' => array(1, 3), 192 + 'BYDAY' => array('TU', 'TH'), 193 + ); 194 + $expect[] = array( 195 + '19980101', 196 + '19980203', 197 + '19980303', 198 + ); 199 + 200 + $tests[] = array( 201 + 'BYMONTHDAY' => array(1, 3), 202 + 'BYDAY' => array('TU', 'TH'), 203 + 'BYMONTH' => array(1, 3), 204 + ); 205 + $expect[] = array( 206 + '19980101', 207 + '19980303', 208 + '20010301', 209 + ); 210 + 211 + $tests[] = array( 212 + 'BYDAY' => array('1TU', '-1TH'), 213 + ); 214 + $expect[] = array( 215 + '19971225', 216 + '19980106', 217 + '19981231', 218 + ); 219 + 220 + // Same test as above, just making sure the optional "+" syntax works. 221 + $tests[] = array( 222 + 'BYDAY' => array('+1TU', '-1TH'), 223 + ); 224 + $expect[] = array( 225 + '19971225', 226 + '19980106', 227 + '19981231', 228 + ); 229 + 230 + $tests[] = array( 231 + 'BYDAY' => array('3TU', '-3TH'), 232 + ); 233 + $expect[] = array( 234 + '19971211', 235 + '19980120', 236 + '19981217', 237 + ); 238 + 239 + $tests[] = array( 240 + 'BYMONTH' => array(1, 3), 241 + 'BYDAY' => array('1TU', '-1TH'), 242 + ); 243 + $expect[] = array( 244 + '19980106', 245 + '19980129', 246 + '19980303', 247 + ); 248 + 249 + $tests[] = array( 250 + 'BYMONTH' => array(1, 3), 251 + 'BYDAY' => array('3TU', '-3TH'), 252 + ); 253 + $expect[] = array( 254 + '19980115', 255 + '19980120', 256 + '19980312', 257 + ); 258 + 259 + $tests[] = array( 260 + 'BYYEARDAY' => array(1, 100, 200, 365), 261 + 'COUNT' => 4, 262 + ); 263 + $expect[] = array( 264 + '19971231', 265 + '19980101', 266 + '19980410', 267 + '19980719', 268 + ); 269 + 270 + $tests[] = array( 271 + 'BYYEARDAY' => array(-365, -266, -166, -1), 272 + 'COUNT' => 4, 273 + ); 274 + $expect[] = array( 275 + '19971231', 276 + '19980101', 277 + '19980410', 278 + '19980719', 279 + ); 280 + 281 + $tests[] = array( 282 + 'BYYEARDAY' => array(1, 100, 200, 365), 283 + 'BYMONTH' => array(4, 7), 284 + 'COUNT' => 4, 285 + ); 286 + $expect[] = array( 287 + '19980410', 288 + '19980719', 289 + '19990410', 290 + '19990719', 291 + ); 292 + 293 + $tests[] = array( 294 + 'BYYEARDAY' => array(-365, -266, -166, -1), 295 + 'BYMONTH' => array(4, 7), 296 + 'COUNT' => 4, 297 + ); 298 + $expect[] = array( 299 + '19980410', 300 + '19980719', 301 + '19990410', 302 + '19990719', 303 + ); 304 + 305 + $tests[] = array( 306 + 'BYWEEKNO' => array(20), 307 + ); 308 + $expect[] = array( 309 + '19980511', 310 + '19980512', 311 + '19980513', 312 + ); 313 + 314 + $tests[] = array( 315 + 'BYWEEKNO' => array(1), 316 + 'BYDAY' => array('MO'), 317 + ); 318 + $expect[] = array( 319 + '19971229', 320 + '19990104', 321 + '20000103', 322 + ); 323 + 324 + $tests[] = array( 325 + 'BYWEEKNO' => array(52), 326 + 'BYDAY' => array('SU'), 327 + ); 328 + $expect[] = array( 329 + '19971228', 330 + '19981227', 331 + '20000102', 332 + ); 333 + 334 + $tests[] = array( 335 + 'BYWEEKNO' => array(-1), 336 + 'BYDAY' => array('SU'), 337 + ); 338 + $expect[] = array( 339 + '19971228', 340 + '19990103', 341 + '20000102', 342 + ); 343 + 344 + $tests[] = array( 345 + 'BYWEEKNO' => array(53), 346 + 'BYDAY' => array('MO'), 347 + ); 348 + $expect[] = array( 349 + '19981228', 350 + '20041227', 351 + '20091228', 352 + ); 353 + 354 + $tests[] = array( 355 + 'BYHOUR' => array(6, 18), 356 + ); 357 + $expect[] = array( 358 + '19970902T060000Z', 359 + '19970902T180000Z', 360 + '19980902T060000Z', 361 + ); 362 + 363 + $tests[] = array( 364 + 'BYMINUTE' => array(15, 30), 365 + ); 366 + $expect[] = array( 367 + '19970902T001500Z', 368 + '19970902T003000Z', 369 + '19980902T001500Z', 370 + ); 371 + 372 + $tests[] = array( 373 + 'BYSECOND' => array(10, 20), 374 + ); 375 + $expect[] = array( 376 + '19970902T000010Z', 377 + '19970902T000020Z', 378 + '19980902T000010Z', 379 + ); 380 + 381 + $tests[] = array( 382 + 'BYHOUR' => array(6, 18), 383 + 'BYMINUTE' => array(15, 30), 384 + ); 385 + $expect[] = array( 386 + '19970902T061500Z', 387 + '19970902T063000Z', 388 + '19970902T181500Z', 389 + ); 390 + 391 + $tests[] = array( 392 + 'BYHOUR' => array(6, 18), 393 + 'BYSECOND' => array(10, 20), 394 + ); 395 + $expect[] = array( 396 + '19970902T060010Z', 397 + '19970902T060020Z', 398 + '19970902T180010Z', 399 + ); 400 + 401 + $tests[] = array( 402 + 'BYMINUTE' => array(15, 30), 403 + 'BYSECOND' => array(10, 20), 404 + ); 405 + $expect[] = array( 406 + '19970902T001510Z', 407 + '19970902T001520Z', 408 + '19970902T003010Z', 409 + ); 410 + 411 + $tests[] = array( 412 + 'BYHOUR' => array(6, 18), 413 + 'BYMINUTE' => array(15, 30), 414 + 'BYSECOND' => array(10, 20), 415 + ); 416 + $expect[] = array( 417 + '19970902T061510Z', 418 + '19970902T061520Z', 419 + '19970902T063010Z', 420 + ); 421 + 422 + $tests[] = array( 423 + 'BYMONTHDAY' => array(15), 424 + 'BYHOUR' => array(6, 18), 425 + 'BYSETPOS' => array(3, -3), 426 + ); 427 + $expect[] = array( 428 + '19971115T180000Z', 429 + '19980215T060000Z', 430 + '19981115T180000Z', 431 + ); 432 + 433 + $this->assertRules( 434 + array( 435 + 'FREQ' => 'YEARLY', 436 + 'COUNT' => 3, 437 + 'DTSTART' => '19970902', 438 + ), 439 + $tests, 440 + $expect); 441 + } 442 + 443 + public function testMonthlyRecurrenceRules() { 444 + $tests = array(); 445 + $expect = array(); 446 + 447 + $tests[] = array(); 448 + $expect[] = array( 449 + '19970902', 450 + '19971002', 451 + '19971102', 452 + ); 453 + 454 + $tests[] = array( 455 + 'INTERVAL' => 2, 456 + ); 457 + $expect[] = array( 458 + '19970902', 459 + '19971102', 460 + '19980102', 461 + ); 462 + 463 + $tests[] = array( 464 + 'INTERVAL' => 18, 465 + ); 466 + $expect[] = array( 467 + '19970902', 468 + '19990302', 469 + '20000902', 470 + ); 471 + 472 + $tests[] = array( 473 + 'BYMONTH' => array(1, 3), 474 + ); 475 + $expect[] = array( 476 + '19980102', 477 + '19980302', 478 + '19990102', 479 + ); 480 + 481 + $tests[] = array( 482 + 'BYMONTHDAY' => array(1, 3), 483 + ); 484 + $expect[] = array( 485 + '19970903', 486 + '19971001', 487 + '19971003', 488 + ); 489 + 490 + $tests[] = array( 491 + 'BYMONTHDAY' => array(5, 7), 492 + 'BYMONTH' => array(1, 3), 493 + ); 494 + $expect[] = array( 495 + '19980105', 496 + '19980107', 497 + '19980305', 498 + ); 499 + 500 + $tests[] = array( 501 + 'BYDAY' => array('TU', 'TH'), 502 + ); 503 + $expect[] = array( 504 + '19970902', 505 + '19970904', 506 + '19970909', 507 + ); 508 + 509 + $tests[] = array( 510 + 'BYDAY' => array('3MO'), 511 + ); 512 + $expect[] = array( 513 + '19970915', 514 + '19971020', 515 + '19971117', 516 + ); 517 + 518 + $tests[] = array( 519 + 'BYDAY' => array('1TU', '-1TH'), 520 + ); 521 + $expect[] = array( 522 + '19970902', 523 + '19970925', 524 + '19971007', 525 + ); 526 + 527 + $tests[] = array( 528 + 'BYDAY' => array('3TU', '-3TH'), 529 + ); 530 + $expect[] = array( 531 + '19970911', 532 + '19970916', 533 + '19971016', 534 + ); 535 + 536 + $tests[] = array( 537 + 'BYDAY' => array('TU', 'TH'), 538 + 'BYMONTH' => array(1, 3), 539 + ); 540 + $expect[] = array( 541 + '19980101', 542 + '19980106', 543 + '19980108', 544 + ); 545 + 546 + $tests[] = array( 547 + 'BYMONTH' => array(1, 3), 548 + 'BYDAY' => array('1TU', '-1TH'), 549 + ); 550 + $expect[] = array( 551 + '19980106', 552 + '19980129', 553 + '19980303', 554 + ); 555 + 556 + $tests[] = array( 557 + 'BYMONTH' => array(1, 3), 558 + 'BYDAY' => array('3TU', '-3TH'), 559 + ); 560 + $expect[] = array( 561 + '19980115', 562 + '19980120', 563 + '19980312', 564 + ); 565 + 566 + $tests[] = array( 567 + 'BYMONTHDAY' => array(1, 3), 568 + 'BYDAY' => array('TU', 'TH'), 569 + ); 570 + $expect[] = array( 571 + '19980101', 572 + '19980203', 573 + '19980303', 574 + ); 575 + 576 + $tests[] = array( 577 + 'BYMONTH' => array(1, 3), 578 + 'BYMONTHDAY' => array(1, 3), 579 + 'BYDAY' => array('TU', 'TH'), 580 + ); 581 + $expect[] = array( 582 + '19980101', 583 + '19980303', 584 + '20010301', 585 + ); 586 + 587 + $tests[] = array( 588 + 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'), 589 + 'BYSETPOS' => array(-1), 590 + ); 591 + $expect[] = array( 592 + '19970930', 593 + '19971031', 594 + '19971128', 595 + ); 596 + 597 + $tests[] = array( 598 + 'BYDAY' => array('1MO', '1TU', '1WE', '1TH', '1FR', '-1FR'), 599 + 'BYMONTHDAY' => array(1, -1, -2), 600 + ); 601 + $expect[] = array( 602 + '19971001', 603 + '19971031', 604 + '19971201', 605 + ); 606 + 607 + $tests[] = array( 608 + 'BYDAY' => array('1MO', '1TU', '1WE', '1TH', 'FR'), 609 + 'BYMONTHDAY' => array(1, -1, -2), 610 + ); 611 + $expect[] = array( 612 + '19971001', 613 + '19971031', 614 + '19971201', 615 + ); 616 + 617 + $tests[] = array( 618 + 'BYHOUR' => array(6, 18), 619 + ); 620 + $expect[] = array( 621 + '19970902T060000Z', 622 + '19970902T180000Z', 623 + '19971002T060000Z', 624 + ); 625 + 626 + $tests[] = array( 627 + 'BYMINUTE' => array(6, 18), 628 + ); 629 + $expect[] = array( 630 + '19970902T000600Z', 631 + '19970902T001800Z', 632 + '19971002T000600Z', 633 + ); 634 + 635 + $tests[] = array( 636 + 'BYSECOND' => array(6, 18), 637 + ); 638 + $expect[] = array( 639 + '19970902T000006Z', 640 + '19970902T000018Z', 641 + '19971002T000006Z', 642 + ); 643 + 644 + $tests[] = array( 645 + 'BYMONTHDAY' => array(13, 17), 646 + 'BYHOUR' => array(6, 18), 647 + 'BYSETPOS' => array(3, -3), 648 + ); 649 + $expect[] = array( 650 + '19970913T180000Z', 651 + '19970917T060000Z', 652 + '19971013T180000Z', 653 + ); 654 + 655 + $tests[] = array( 656 + 'BYMONTHDAY' => array(13, 17), 657 + 'BYHOUR' => array(6, 18), 658 + 'BYSETPOS' => array(3, 3, -3), 659 + ); 660 + $expect[] = array( 661 + '19970913T180000Z', 662 + '19970917T060000Z', 663 + '19971013T180000Z', 664 + ); 665 + 666 + $tests[] = array( 667 + 'BYMONTHDAY' => array(13, 17), 668 + 'BYHOUR' => array(6, 18), 669 + 'BYSETPOS' => array(4, -1), 670 + ); 671 + $expect[] = array( 672 + '19970917T180000Z', 673 + '19971017T180000Z', 674 + '19971117T180000Z', 675 + ); 676 + 677 + $this->assertRules( 678 + array( 679 + 'FREQ' => 'MONTHLY', 680 + 'COUNT' => 3, 681 + 'DTSTART' => '19970902', 682 + ), 683 + $tests, 684 + $expect); 685 + } 686 + 687 + public function testWeeklyRecurrenceRules() { 688 + $tests = array(); 689 + $expect = array(); 690 + 691 + $tests[] = array(); 692 + $expect[] = array( 693 + '19970902', 694 + '19970909', 695 + '19970916', 696 + ); 697 + 698 + $tests[] = array( 699 + 'INTERVAL' => 2, 700 + ); 701 + $expect[] = array( 702 + '19970902', 703 + '19970916', 704 + '19970930', 705 + ); 706 + 707 + $tests[] = array( 708 + 'INTERVAL' => 20, 709 + ); 710 + $expect[] = array( 711 + '19970902', 712 + '19980120', 713 + '19980609', 714 + ); 715 + 716 + $tests[] = array( 717 + 'BYMONTH' => array(1, 3), 718 + ); 719 + $expect[] = array( 720 + '19980106', 721 + '19980113', 722 + '19980120', 723 + ); 724 + 725 + $tests[] = array( 726 + 'BYDAY' => array('TU', 'TH'), 727 + ); 728 + $expect[] = array( 729 + '19970902', 730 + '19970904', 731 + '19970909', 732 + ); 733 + 734 + $tests[] = array( 735 + 'BYMONTH' => array(1, 3), 736 + 'BYDAY' => array('TU', 'TH'), 737 + ); 738 + $expect[] = array( 739 + '19980101', 740 + '19980106', 741 + '19980108', 742 + ); 743 + 744 + $tests[] = array( 745 + 'BYHOUR' => array(6, 18), 746 + ); 747 + $expect[] = array( 748 + '19970902T060000Z', 749 + '19970902T180000Z', 750 + '19970909T060000Z', 751 + ); 752 + 753 + $tests[] = array( 754 + 'BYDAY' => array('TU', 'TH'), 755 + 'BYHOUR' => array(6, 18), 756 + 'BYSETPOS' => array(3, -3), 757 + 'DTSTART' => '19970902T090000Z', 758 + ); 759 + $expect[] = array( 760 + '19970902T180000Z', 761 + '19970904T060000Z', 762 + '19970909T180000Z', 763 + ); 764 + 765 + $this->assertRules( 766 + array( 767 + 'FREQ' => 'WEEKLY', 768 + 'COUNT' => 3, 769 + 'DTSTART' => '19970902', 770 + ), 771 + $tests, 772 + $expect); 773 + } 774 + 775 + public function testDailyRecurrenceRules() { 776 + $tests = array(); 777 + $expect = array(); 778 + 779 + $tests[] = array(); 780 + $expect[] = array( 781 + '19970902', 782 + '19970903', 783 + '19970904', 784 + ); 785 + 786 + $tests[] = array( 787 + 'INTERVAL' => 2, 788 + ); 789 + $expect[] = array( 790 + '19970902', 791 + '19970904', 792 + '19970906', 793 + ); 794 + 795 + $tests[] = array( 796 + 'INTERVAL' => 92, 797 + ); 798 + $expect[] = array( 799 + '19970902', 800 + '19971203', 801 + '19980305', 802 + ); 803 + 804 + $tests[] = array( 805 + 'BYMONTH' => array(1, 3), 806 + ); 807 + $expect[] = array( 808 + '19980101', 809 + '19980102', 810 + '19980103', 811 + ); 812 + 813 + // This is testing that INTERVAL is respected in the presence of a BYMONTH 814 + // filter which skips some months. 815 + $tests[] = array( 816 + 'BYMONTH' => array(12), 817 + 'INTERVAL' => 17, 818 + ); 819 + $expect[] = array( 820 + '19971213', 821 + '19971230', 822 + '19981205', 823 + ); 824 + 825 + $tests[] = array( 826 + 'BYMONTHDAY' => array(1, 3), 827 + ); 828 + $expect[] = array( 829 + '19970903', 830 + '19971001', 831 + '19971003', 832 + ); 833 + 834 + $tests[] = array( 835 + 'BYMONTH' => array(1, 3), 836 + 'BYMONTHDAY' => array(5, 7), 837 + ); 838 + $expect[] = array( 839 + '19980105', 840 + '19980107', 841 + '19980305', 842 + ); 843 + 844 + $tests[] = array( 845 + 'BYDAY' => array('TU', 'TH'), 846 + ); 847 + $expect[] = array( 848 + '19970902', 849 + '19970904', 850 + '19970909', 851 + ); 852 + 853 + $tests[] = array( 854 + 'BYMONTH' => array(1, 3), 855 + 'BYDAY' => array('TU', 'TH'), 856 + ); 857 + $expect[] = array( 858 + '19980101', 859 + '19980106', 860 + '19980108', 861 + ); 862 + 863 + $tests[] = array( 864 + 'BYMONTHDAY' => array(1, 3), 865 + 'BYDAY' => array('TU', 'TH'), 866 + ); 867 + $expect[] = array( 868 + '19980101', 869 + '19980203', 870 + '19980303', 871 + ); 872 + 873 + $tests[] = array( 874 + 'BYMONTH' => array(1, 3), 875 + 'BYMONTHDAY' => array(1, 3), 876 + 'BYDAY' => array('TU', 'TH'), 877 + ); 878 + $expect[] = array( 879 + '19980101', 880 + '19980303', 881 + '20010301', 882 + ); 883 + 884 + $tests[] = array( 885 + 'BYHOUR' => array(6, 18), 886 + 'BYMINUTE' => array(15, 45), 887 + 'BYSETPOS' => array(3, -3), 888 + 'DTSTART' => '19970902T090000Z', 889 + ); 890 + $expect[] = array( 891 + '19970902T181500Z', 892 + '19970903T064500Z', 893 + '19970903T181500Z', 894 + ); 895 + 896 + $this->assertRules( 897 + array( 898 + 'FREQ' => 'DAILY', 899 + 'COUNT' => 3, 900 + 'DTSTART' => '19970902', 901 + ), 902 + $tests, 903 + $expect); 904 + } 905 + 906 + public function testHourlyRecurrenceRules() { 907 + $tests = array(); 908 + $expect = array(); 909 + 910 + $tests[] = array(); 911 + $expect[] = array( 912 + '19970902T090000Z', 913 + '19970902T100000Z', 914 + '19970902T110000Z', 915 + ); 916 + 917 + $tests[] = array( 918 + 'INTERVAL' => 2, 919 + ); 920 + $expect[] = array( 921 + '19970902T090000Z', 922 + '19970902T110000Z', 923 + '19970902T130000Z', 924 + ); 925 + 926 + $tests[] = array( 927 + 'INTERVAL' => 769, 928 + ); 929 + $expect[] = array( 930 + '19970902T090000Z', 931 + '19971004T100000Z', 932 + '19971105T110000Z', 933 + ); 934 + 935 + $tests[] = array( 936 + 'BYMONTH' => array(1, 3), 937 + ); 938 + $expect[] = array( 939 + '19980101T000000Z', 940 + '19980101T010000Z', 941 + '19980101T020000Z', 942 + ); 943 + 944 + $tests[] = array( 945 + 'BYMONTHDAY' => array(1, 3), 946 + ); 947 + $expect[] = array( 948 + '19970903T000000Z', 949 + '19970903T010000Z', 950 + '19970903T020000Z', 951 + ); 952 + 953 + $tests[] = array( 954 + 'BYMONTH' => array(1, 3), 955 + 'BYMONTHDAY' => array(5, 7), 956 + ); 957 + $expect[] = array( 958 + '19980105T000000Z', 959 + '19980105T010000Z', 960 + '19980105T020000Z', 961 + ); 962 + 963 + $tests[] = array( 964 + 'BYDAY' => array('TU', 'TH'), 965 + ); 966 + $expect[] = array( 967 + '19970902T090000Z', 968 + '19970902T100000Z', 969 + '19970902T110000Z', 970 + ); 971 + 972 + $tests[] = array( 973 + 'BYMONTH' => array(1, 3), 974 + 'BYDAY' => array('TU', 'TH'), 975 + ); 976 + $expect[] = array( 977 + '19980101T000000Z', 978 + '19980101T010000Z', 979 + '19980101T020000Z', 980 + ); 981 + 982 + $tests[] = array( 983 + 'BYMONTHDAY' => array(1, 3), 984 + 'BYDAY' => array('TU', 'TH'), 985 + ); 986 + $expect[] = array( 987 + '19980101T000000Z', 988 + '19980101T010000Z', 989 + '19980101T020000Z', 990 + ); 991 + 992 + $tests[] = array( 993 + 'BYMONTHDAY' => array(1, 3), 994 + 'BYMONTH' => array(1, 3), 995 + 'BYDAY' => array('TU', 'TH'), 996 + ); 997 + $expect[] = array( 998 + '19980101T000000Z', 999 + '19980101T010000Z', 1000 + '19980101T020000Z', 1001 + ); 1002 + 1003 + $tests[] = array( 1004 + 'COUNT' => 4, 1005 + 'BYYEARDAY' => array(1, 100, 200, 365), 1006 + ); 1007 + $expect[] = array( 1008 + '19971231T000000Z', 1009 + '19971231T010000Z', 1010 + '19971231T020000Z', 1011 + '19971231T030000Z', 1012 + ); 1013 + 1014 + $tests[] = array( 1015 + 'COUNT' => 4, 1016 + 'BYYEARDAY' => array(-365, -266, -166, -1), 1017 + ); 1018 + $expect[] = array( 1019 + '19971231T000000Z', 1020 + '19971231T010000Z', 1021 + '19971231T020000Z', 1022 + '19971231T030000Z', 1023 + ); 1024 + 1025 + $tests[] = array( 1026 + 'COUNT' => 4, 1027 + 'BYMONTH' => array(4, 7), 1028 + 'BYYEARDAY' => array(1, 100, 200, 365), 1029 + ); 1030 + $expect[] = array( 1031 + '19980410T000000Z', 1032 + '19980410T010000Z', 1033 + '19980410T020000Z', 1034 + '19980410T030000Z', 1035 + ); 1036 + 1037 + $tests[] = array( 1038 + 'COUNT' => 4, 1039 + 'BYMONTH' => array(4, 7), 1040 + 'BYYEARDAY' => array(-365, -266, -166, -1), 1041 + ); 1042 + $expect[] = array( 1043 + '19980410T000000Z', 1044 + '19980410T010000Z', 1045 + '19980410T020000Z', 1046 + '19980410T030000Z', 1047 + ); 1048 + 1049 + $tests[] = array( 1050 + 'BYHOUR' => array(6, 18), 1051 + ); 1052 + $expect[] = array( 1053 + '19970902T180000Z', 1054 + '19970903T060000Z', 1055 + '19970903T180000Z', 1056 + ); 1057 + 1058 + $tests[] = array( 1059 + 'BYMINUTE' => array(15, 45), 1060 + 'BYSECOND' => array(15, 45), 1061 + 'BYSETPOS' => array(3, -3), 1062 + ); 1063 + $expect[] = array( 1064 + '19970902T091545Z', 1065 + '19970902T094515Z', 1066 + '19970902T101545Z', 1067 + ); 1068 + 1069 + $this->assertRules( 1070 + array( 1071 + 'FREQ' => 'HOURLY', 1072 + 'COUNT' => 3, 1073 + 'DTSTART' => '19970902T090000Z', 1074 + ), 1075 + $tests, 1076 + $expect); 1077 + } 1078 + 1079 + public function testMinutelyRecurrenceRules() { 1080 + $tests = array(); 1081 + $expect = array(); 1082 + 1083 + $tests[] = array( 1084 + ); 1085 + $expect[] = array( 1086 + '19970902T090000Z', 1087 + '19970902T090100Z', 1088 + '19970902T090200Z', 1089 + ); 1090 + 1091 + $tests[] = array( 1092 + 'INTERVAL' => 2, 1093 + ); 1094 + $expect[] = array( 1095 + '19970902T090000Z', 1096 + '19970902T090200Z', 1097 + '19970902T090400Z', 1098 + ); 1099 + 1100 + $tests[] = array( 1101 + 'BYHOUR' => array(6, 18), 1102 + 'BYMINUTE' => array(6, 18), 1103 + 'BYSECOND' => array(6, 18), 1104 + ); 1105 + $expect[] = array( 1106 + '19970902T180606Z', 1107 + '19970902T180618Z', 1108 + '19970902T181806Z', 1109 + ); 1110 + 1111 + $tests[] = array( 1112 + 'BYSECOND' => array(15, 30, 45), 1113 + 'BYSETPOS' => array(3, -3), 1114 + ); 1115 + $expect[] = array( 1116 + '19970902T090015Z', 1117 + '19970902T090045Z', 1118 + '19970902T090115Z', 1119 + ); 1120 + 1121 + $this->assertRules( 1122 + array( 1123 + 'FREQ' => 'MINUTELY', 1124 + 'COUNT' => 3, 1125 + 'DTSTART' => '19970902T090000Z', 1126 + ), 1127 + $tests, 1128 + $expect); 1129 + } 1130 + 1131 + public function testSecondlyRecurrenceRules() { 1132 + $tests = array(); 1133 + $expect = array(); 1134 + 1135 + $tests[] = array(); 1136 + $expect[] = array( 1137 + '19970902T090000Z', 1138 + '19970902T090001Z', 1139 + '19970902T090002Z', 1140 + ); 1141 + 1142 + $tests[] = array( 1143 + 'INTERVAL' => 2, 1144 + ); 1145 + $expect[] = array( 1146 + '19970902T090000Z', 1147 + '19970902T090002Z', 1148 + '19970902T090004Z', 1149 + ); 1150 + 1151 + $tests[] = array( 1152 + 'INTERVAL' => 90061, 1153 + ); 1154 + $expect[] = array( 1155 + '19970902T090000Z', 1156 + '19970903T100101Z', 1157 + '19970904T110202Z', 1158 + ); 1159 + 1160 + $tests[] = array( 1161 + 'BYSECOND' => array(0), 1162 + 'BYMINUTE' => array(1), 1163 + 'DTSTART' => '20100322T120100Z', 1164 + ); 1165 + $expect[] = array( 1166 + '20100322T120100Z', 1167 + '20100322T130100Z', 1168 + '20100322T140100Z', 1169 + ); 1170 + 1171 + $this->assertRules( 1172 + array( 1173 + 'FREQ' => 'SECONDLY', 1174 + 'COUNT' => 3, 1175 + 'DTSTART' => '19970902T090000Z', 1176 + ), 1177 + $tests, 1178 + $expect); 1179 + } 1180 + 1181 + public function testRFC5545RecurrenceRules() { 1182 + // These tests are derived from the examples in RFC5545. 1183 + $tests = array(); 1184 + $expect = array(); 1185 + 1186 + $tests[] = array( 1187 + 'FREQ' => 'DAILY', 1188 + 'COUNT' => 10, 1189 + 'DTSTART' => '19970902T090000Z', 1190 + ); 1191 + $expect[] = array( 1192 + '19970902T090000Z', 1193 + '19970903T090000Z', 1194 + '19970904T090000Z', 1195 + '19970905T090000Z', 1196 + '19970906T090000Z', 1197 + '19970907T090000Z', 1198 + '19970908T090000Z', 1199 + '19970909T090000Z', 1200 + '19970910T090000Z', 1201 + '19970911T090000Z', 1202 + ); 1203 + 1204 + $tests[] = array( 1205 + 'FREQ' => 'DAILY', 1206 + 'INTERVAL' => 2, 1207 + 'DTSTART' => '19970902T090000Z', 1208 + 'COUNT' => 5, 1209 + ); 1210 + $expect[] = array( 1211 + '19970902T090000Z', 1212 + '19970904T090000Z', 1213 + '19970906T090000Z', 1214 + '19970908T090000Z', 1215 + '19970910T090000Z', 1216 + ); 1217 + 1218 + $tests[] = array( 1219 + 'FREQ' => 'YEARLY', 1220 + 'BYMONTH' => array(1), 1221 + 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'), 1222 + 'DTSTART' => '19970902T090000Z', 1223 + 'COUNT' => 3, 1224 + ); 1225 + $expect[] = array( 1226 + '19980101T090000Z', 1227 + '19980102T090000Z', 1228 + '19980103T090000Z', 1229 + ); 1230 + 1231 + $tests[] = array( 1232 + 'FREQ' => 'MONTHLY', 1233 + 'COUNT' => 3, 1234 + 'BYDAY' => array('1FR'), 1235 + 'DTSTART' => '19970902T090000Z', 1236 + ); 1237 + $expect[] = array( 1238 + '19970905T090000Z', 1239 + '19971003T090000Z', 1240 + '19971107T090000Z', 1241 + ); 1242 + 1243 + $tests[] = array( 1244 + 'FREQ' => 'MONTHLY', 1245 + 'INTERVAL' => 2, 1246 + 'COUNT' => 5, 1247 + 'BYDAY' => array('1SU', '-1SU'), 1248 + 'DTSTART' => '19970902T090000Z', 1249 + ); 1250 + $expect[] = array( 1251 + '19970907T090000Z', 1252 + '19970928T090000Z', 1253 + '19971102T090000Z', 1254 + '19971130T090000Z', 1255 + '19980104T090000Z', 1256 + ); 1257 + 1258 + $tests[] = array( 1259 + 'FREQ' => 'MONTHLY', 1260 + 'COUNT' => 6, 1261 + 'BYDAY' => array('-2MO'), 1262 + 'DTSTART' => '19970902T090000Z', 1263 + ); 1264 + $expect[] = array( 1265 + '19970922T090000Z', 1266 + '19971020T090000Z', 1267 + '19971117T090000Z', 1268 + '19971222T090000Z', 1269 + '19980119T090000Z', 1270 + '19980216T090000Z', 1271 + ); 1272 + 1273 + $tests[] = array( 1274 + 'FREQ' => 'MONTHLY', 1275 + 'COUNT' => 6, 1276 + 'BYMONTHDAY' => array(-3), 1277 + 'DTSTART' => '19970902T090000Z', 1278 + ); 1279 + $expect[] = array( 1280 + '19970928T090000Z', 1281 + '19971029T090000Z', 1282 + '19971128T090000Z', 1283 + '19971229T090000Z', 1284 + '19980129T090000Z', 1285 + '19980226T090000Z', 1286 + ); 1287 + 1288 + $tests[] = array( 1289 + 'FREQ' => 'MONTHLY', 1290 + 'COUNT' => 5, 1291 + 'BYMONTHDAY' => array(2, 15), 1292 + 'DTSTART' => '19970902T090000Z', 1293 + ); 1294 + $expect[] = array( 1295 + '19970902T090000Z', 1296 + '19970915T090000Z', 1297 + '19971002T090000Z', 1298 + '19971015T090000Z', 1299 + '19971102T090000Z', 1300 + ); 1301 + 1302 + $tests[] = array( 1303 + 'FREQ' => 'MONTHLY', 1304 + 'COUNT' => 5, 1305 + 'BYMONTHDAY' => array(-1, 1), 1306 + 'DTSTART' => '19970902T090000Z', 1307 + ); 1308 + $expect[] = array( 1309 + '19970930T090000Z', 1310 + '19971001T090000Z', 1311 + '19971031T090000Z', 1312 + '19971101T090000Z', 1313 + '19971130T090000Z', 1314 + ); 1315 + 1316 + $tests[] = array( 1317 + 'FREQ' => 'MONTHLY', 1318 + 'COUNT' => 7, 1319 + 'INTERVAL' => 18, 1320 + 'BYMONTHDAY' => array(10, 11, 12, 13, 14, 15), 1321 + 'DTSTART' => '19970902T090000Z', 1322 + ); 1323 + $expect[] = array( 1324 + '19970910T090000Z', 1325 + '19970911T090000Z', 1326 + '19970912T090000Z', 1327 + '19970913T090000Z', 1328 + '19970914T090000Z', 1329 + '19970915T090000Z', 1330 + '19990310T090000Z', 1331 + ); 1332 + 1333 + $tests[] = array( 1334 + 'FREQ' => 'MONTHLY', 1335 + 'COUNT' => 6, 1336 + 'INTERVAL' => 2, 1337 + 'BYDAY' => array('TU'), 1338 + 'DTSTART' => '19970902T090000Z', 1339 + ); 1340 + $expect[] = array( 1341 + '19970902T090000Z', 1342 + '19970909T090000Z', 1343 + '19970916T090000Z', 1344 + '19970923T090000Z', 1345 + '19970930T090000Z', 1346 + '19971104T090000Z', 1347 + ); 1348 + 1349 + $tests[] = array( 1350 + 'FREQ' => 'YEARLY', 1351 + 'COUNT' => 10, 1352 + 'BYMONTH' => array(6, 7), 1353 + 'DTSTART' => '19970610T090000Z', 1354 + ); 1355 + $expect[] = array( 1356 + '19970610T090000Z', 1357 + '19970710T090000Z', 1358 + '19980610T090000Z', 1359 + '19980710T090000Z', 1360 + '19990610T090000Z', 1361 + '19990710T090000Z', 1362 + '20000610T090000Z', 1363 + '20000710T090000Z', 1364 + '20010610T090000Z', 1365 + '20010710T090000Z', 1366 + ); 1367 + 1368 + $tests[] = array( 1369 + 'FREQ' => 'YEARLY', 1370 + 'COUNT' => 4, 1371 + 'INTERVAL' => 3, 1372 + 'BYYEARDAY' => array(1, 100, 200), 1373 + 'DTSTART' => '19970101T090000Z', 1374 + ); 1375 + $expect[] = array( 1376 + '19970101T090000Z', 1377 + '19970410T090000Z', 1378 + '19970719T090000Z', 1379 + '20000101T090000Z', 1380 + ); 1381 + 1382 + $tests[] = array( 1383 + 'FREQ' => 'YEARLY', 1384 + 'COUNT' => 3, 1385 + 'BYDAY' => array('20MO'), 1386 + 'DTSTART' => '19970519T090000Z', 1387 + ); 1388 + $expect[] = array( 1389 + '19970519T090000Z', 1390 + '19980518T090000Z', 1391 + '19990517T090000Z', 1392 + ); 1393 + 1394 + $tests[] = array( 1395 + 'FREQ' => 'YEARLY', 1396 + 'COUNT' => 3, 1397 + 'BYWEEKNO' => array(20), 1398 + 'BYDAY' => array('MO'), 1399 + 'DTSTART' => '19970512T090000Z', 1400 + ); 1401 + $expect[] = array( 1402 + '19970512T090000Z', 1403 + '19980511T090000Z', 1404 + '19990517T090000Z', 1405 + ); 1406 + 1407 + $tests[] = array( 1408 + 'FREQ' => 'YEARLY', 1409 + 'BYDAY' => array('TH'), 1410 + 'BYMONTH' => array(3), 1411 + 'DTSTART' => '19970313T090000Z', 1412 + 'COUNT' => 5, 1413 + ); 1414 + $expect[] = array( 1415 + '19970313T090000Z', 1416 + '19970320T090000Z', 1417 + '19970327T090000Z', 1418 + '19980305T090000Z', 1419 + '19980312T090000Z', 1420 + ); 1421 + 1422 + $tests[] = array( 1423 + 'FREQ' => 'YEARLY', 1424 + 'BYDAY' => array('TH'), 1425 + 'BYMONTH' => array(6, 7, 8), 1426 + 'DTSTART' => '19970101T090000Z', 1427 + 'COUNT' => 15, 1428 + ); 1429 + $expect[] = array( 1430 + '19970605T090000Z', 1431 + '19970612T090000Z', 1432 + '19970619T090000Z', 1433 + '19970626T090000Z', 1434 + '19970703T090000Z', 1435 + '19970710T090000Z', 1436 + '19970717T090000Z', 1437 + '19970724T090000Z', 1438 + '19970731T090000Z', 1439 + '19970807T090000Z', 1440 + '19970814T090000Z', 1441 + '19970821T090000Z', 1442 + '19970828T090000Z', 1443 + '19980604T090000Z', 1444 + '19980611T090000Z', 1445 + ); 1446 + 1447 + $tests[] = array( 1448 + 'FREQ' => 'YEARLY', 1449 + 'BYDAY' => array('FR'), 1450 + 'BYMONTHDAY' => array(13), 1451 + 'COUNT' => 4, 1452 + 'DTSTART' => '19970902T090000Z', 1453 + ); 1454 + $expect[] = array( 1455 + '19980213T090000Z', 1456 + '19980313T090000Z', 1457 + '19981113T090000Z', 1458 + '19990813T090000Z', 1459 + ); 1460 + 1461 + $tests[] = array( 1462 + 'FREQ' => 'MONTHLY', 1463 + 'BYDAY' => array('SA'), 1464 + 'BYMONTHDAY' => array(7, 8, 9, 10, 11, 12, 13), 1465 + 'COUNT' => 10, 1466 + 'DTSTART' => '19970902T090000Z', 1467 + ); 1468 + $expect[] = array( 1469 + '19970913T090000Z', 1470 + '19971011T090000Z', 1471 + '19971108T090000Z', 1472 + '19971213T090000Z', 1473 + '19980110T090000Z', 1474 + '19980207T090000Z', 1475 + '19980307T090000Z', 1476 + '19980411T090000Z', 1477 + '19980509T090000Z', 1478 + '19980613T090000Z', 1479 + ); 1480 + 1481 + $tests[] = array( 1482 + 'FREQ' => 'YEARLY', 1483 + 'INTERVAL' => 4, 1484 + 'BYMONTH' => array(11), 1485 + 'BYDAY' => array('TU'), 1486 + 'BYMONTHDAY' => array(2, 3, 4, 5, 6, 7, 8), 1487 + 'COUNT' => 6, 1488 + 'DTSTART' => '19961105T090000Z', 1489 + ); 1490 + $expect[] = array( 1491 + '19961105T090000Z', 1492 + '20001107T090000Z', 1493 + '20041102T090000Z', 1494 + '20081104T090000Z', 1495 + '20121106T090000Z', 1496 + '20161108T090000Z', 1497 + ); 1498 + 1499 + $tests[] = array( 1500 + 'FREQ' => 'MONTHLY', 1501 + 'BYDAY' => array('TU', 'WE', 'TH'), 1502 + 'BYSETPOS' => array(3), 1503 + 'COUNT' => 3, 1504 + 'DTSTART' => '19970904T090000Z', 1505 + ); 1506 + $expect[] = array( 1507 + '19970904T090000Z', 1508 + '19971007T090000Z', 1509 + '19971106T090000Z', 1510 + ); 1511 + 1512 + $tests[] = array( 1513 + 'FREQ' => 'MONTHLY', 1514 + 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'), 1515 + 'BYSETPOS' => array(-2), 1516 + 'COUNT' => 3, 1517 + 'DTSTART' => '19970929T090000Z', 1518 + ); 1519 + $expect[] = array( 1520 + '19970929T090000Z', 1521 + '19971030T090000Z', 1522 + '19971127T090000Z', 1523 + ); 1524 + 1525 + $tests[] = array( 1526 + 'FREQ' => 'HOURLY', 1527 + 'INTERVAL' => 3, 1528 + 'DTSTART' => '19970929T090000Z', 1529 + 'COUNT' => 3, 1530 + ); 1531 + $expect[] = array( 1532 + '19970929T090000Z', 1533 + '19970929T120000Z', 1534 + '19970929T150000Z', 1535 + ); 1536 + 1537 + $tests[] = array( 1538 + 'FREQ' => 'MINUTELY', 1539 + 'INTERVAL' => 15, 1540 + 'COUNT' => 6, 1541 + 'DTSTART' => '19970902T090000Z', 1542 + ); 1543 + $expect[] = array( 1544 + '19970902T090000Z', 1545 + '19970902T091500Z', 1546 + '19970902T093000Z', 1547 + '19970902T094500Z', 1548 + '19970902T100000Z', 1549 + '19970902T101500Z', 1550 + ); 1551 + 1552 + $tests[] = array( 1553 + 'FREQ' => 'MINUTELY', 1554 + 'INTERVAL' => 90, 1555 + 'COUNT' => 4, 1556 + 'DTSTART' => '19970902T090000Z', 1557 + ); 1558 + $expect[] = array( 1559 + '19970902T090000Z', 1560 + '19970902T103000Z', 1561 + '19970902T120000Z', 1562 + '19970902T133000Z', 1563 + ); 1564 + 1565 + $tests[] = array( 1566 + 'FREQ' => 'WEEKLY', 1567 + 'COUNT' => 10, 1568 + 'DTSTART' => '19970902T090000Z', 1569 + ); 1570 + $expect[] = array( 1571 + '19970902T090000Z', 1572 + '19970909T090000Z', 1573 + '19970916T090000Z', 1574 + '19970923T090000Z', 1575 + '19970930T090000Z', 1576 + '19971007T090000Z', 1577 + '19971014T090000Z', 1578 + '19971021T090000Z', 1579 + '19971028T090000Z', 1580 + '19971104T090000Z', 1581 + ); 1582 + 1583 + $tests[] = array( 1584 + 'FREQ' => 'WEEKLY', 1585 + 'INTERVAL' => 2, 1586 + 'COUNT' => 6, 1587 + 'DTSTART' => '19970902T090000Z', 1588 + ); 1589 + $expect[] = array( 1590 + '19970902T090000Z', 1591 + '19970916T090000Z', 1592 + '19970930T090000Z', 1593 + '19971014T090000Z', 1594 + '19971028T090000Z', 1595 + '19971111T090000Z', 1596 + ); 1597 + 1598 + $tests[] = array( 1599 + 'FREQ' => 'WEEKLY', 1600 + 'COUNT' => 10, 1601 + 'WKST' => 'SU', 1602 + 'BYDAY' => array('TU', 'TH'), 1603 + 'DTSTART' => '19970902T090000Z', 1604 + ); 1605 + $expect[] = array( 1606 + '19970902T090000Z', 1607 + '19970904T090000Z', 1608 + '19970909T090000Z', 1609 + '19970911T090000Z', 1610 + '19970916T090000Z', 1611 + '19970918T090000Z', 1612 + '19970923T090000Z', 1613 + '19970925T090000Z', 1614 + '19970930T090000Z', 1615 + '19971002T090000Z', 1616 + ); 1617 + 1618 + $tests[] = array( 1619 + 'FREQ' => 'WEEKLY', 1620 + 'INTERVAL' => 2, 1621 + 'COUNT' => 8, 1622 + 'WKST' => 'SU', 1623 + 'BYDAY' => array('TU', 'TH'), 1624 + 'DTSTART' => '19970902T090000Z', 1625 + ); 1626 + $expect[] = array( 1627 + '19970902T090000Z', 1628 + '19970904T090000Z', 1629 + '19970916T090000Z', 1630 + '19970918T090000Z', 1631 + '19970930T090000Z', 1632 + '19971002T090000Z', 1633 + '19971014T090000Z', 1634 + '19971016T090000Z', 1635 + ); 1636 + 1637 + $tests[] = array( 1638 + 'FREQ' => 'WEEKLY', 1639 + 'INTERVAL' => 2, 1640 + 'COUNT' => 4, 1641 + 'BYDAY' => array('TU', 'SU'), 1642 + 'WKST' => 'MO', 1643 + 'DTSTART' => '19970805T090000Z', 1644 + ); 1645 + $expect[] = array( 1646 + '19970805T090000Z', 1647 + '19970810T090000Z', 1648 + '19970819T090000Z', 1649 + '19970824T090000Z', 1650 + ); 1651 + 1652 + $tests[] = array( 1653 + 'FREQ' => 'WEEKLY', 1654 + 'INTERVAL' => 2, 1655 + 'COUNT' => 4, 1656 + 'BYDAY' => array('TU', 'SU'), 1657 + 'WKST' => 'SU', 1658 + 'DTSTART' => '19970805T090000Z', 1659 + ); 1660 + $expect[] = array( 1661 + '19970805T090000Z', 1662 + '19970817T090000Z', 1663 + '19970819T090000Z', 1664 + '19970831T090000Z', 1665 + ); 1666 + 1667 + 1668 + $this->assertRules(array(), $tests, $expect); 1669 + } 1670 + 1671 + 1672 + private function assertRules(array $defaults, array $tests, array $expect) { 1673 + foreach ($tests as $key => $test) { 1674 + $options = $test + $defaults; 1675 + 1676 + $start = PhutilCalendarAbsoluteDateTime::newFromISO8601( 1677 + $options['DTSTART']); 1678 + 1679 + $rrule = id(new PhutilCalendarRecurrenceRule()) 1680 + ->setStartDateTime($start) 1681 + ->setFrequency($options['FREQ']); 1682 + 1683 + $interval = idx($options, 'INTERVAL'); 1684 + if ($interval) { 1685 + $rrule->setInterval($interval); 1686 + } 1687 + 1688 + $by_day = idx($options, 'BYDAY'); 1689 + if ($by_day) { 1690 + $rrule->setByDay($by_day); 1691 + } 1692 + 1693 + $by_month = idx($options, 'BYMONTH'); 1694 + if ($by_month) { 1695 + $rrule->setByMonth($by_month); 1696 + } 1697 + 1698 + $by_monthday = idx($options, 'BYMONTHDAY'); 1699 + if ($by_monthday) { 1700 + $rrule->setByMonthDay($by_monthday); 1701 + } 1702 + 1703 + $by_yearday = idx($options, 'BYYEARDAY'); 1704 + if ($by_yearday) { 1705 + $rrule->setByYearDay($by_yearday); 1706 + } 1707 + 1708 + $by_weekno = idx($options, 'BYWEEKNO'); 1709 + if ($by_weekno) { 1710 + $rrule->setByWeekNumber($by_weekno); 1711 + } 1712 + 1713 + $by_hour = idx($options, 'BYHOUR'); 1714 + if ($by_hour) { 1715 + $rrule->setByHour($by_hour); 1716 + } 1717 + 1718 + $by_minute = idx($options, 'BYMINUTE'); 1719 + if ($by_minute) { 1720 + $rrule->setByMinute($by_minute); 1721 + } 1722 + 1723 + $by_second = idx($options, 'BYSECOND'); 1724 + if ($by_second) { 1725 + $rrule->setBySecond($by_second); 1726 + } 1727 + 1728 + $by_setpos = idx($options, 'BYSETPOS'); 1729 + if ($by_setpos) { 1730 + $rrule->setBySetPosition($by_setpos); 1731 + } 1732 + 1733 + $week_start = idx($options, 'WKST'); 1734 + if ($week_start) { 1735 + $rrule->setWeekStart($week_start); 1736 + } 1737 + 1738 + $set = id(new PhutilCalendarRecurrenceSet()) 1739 + ->addSource($rrule); 1740 + 1741 + $result = $set->getEventsBetween(null, null, $options['COUNT']); 1742 + 1743 + $this->assertEqual( 1744 + $expect[$key], 1745 + mpull($result, 'getISO8601')); 1746 + } 1747 + } 1748 + 1749 + 1750 + }
+196
src/applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceTestCase.php
··· 1 + <?php 2 + 3 + final class PhutilCalendarRecurrenceTestCase extends PhutilTestCase { 4 + 5 + public function testCalendarRecurrenceLists() { 6 + $set = id(new PhutilCalendarRecurrenceSet()); 7 + $result = $set->getEventsBetween(null, null, 0xFFFF); 8 + $this->assertEqual( 9 + array(), 10 + $result, 11 + pht('Set with no sources.')); 12 + 13 + 14 + $set = id(new PhutilCalendarRecurrenceSet()) 15 + ->addSource(new PhutilCalendarRecurrenceList()); 16 + $result = $set->getEventsBetween(null, null, 0xFFFF); 17 + $this->assertEqual( 18 + array(), 19 + $result, 20 + pht('Set with empty list source.')); 21 + 22 + 23 + $list = array( 24 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 25 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 26 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 27 + ); 28 + 29 + $source = id(new PhutilCalendarRecurrenceList()) 30 + ->setDates($list); 31 + 32 + $set = id(new PhutilCalendarRecurrenceSet()) 33 + ->addSource($source); 34 + 35 + $expect = array( 36 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 37 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 38 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 39 + ); 40 + 41 + $result = $set->getEventsBetween(null, null, 0xFFFF); 42 + $this->assertEqual( 43 + mpull($expect, 'getISO8601'), 44 + mpull($result, 'getISO8601'), 45 + pht('Simple date list.')); 46 + 47 + $list_a = array( 48 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 49 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 50 + ); 51 + 52 + $list_b = array( 53 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 54 + ); 55 + 56 + $source_a = id(new PhutilCalendarRecurrenceList()) 57 + ->setDates($list_a); 58 + 59 + $source_b = id(new PhutilCalendarRecurrenceList()) 60 + ->setDates($list_b); 61 + 62 + $set = id(new PhutilCalendarRecurrenceSet()) 63 + ->addSource($source_a) 64 + ->addSource($source_b); 65 + 66 + $expect = array( 67 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 68 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 69 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 70 + ); 71 + 72 + $result = $set->getEventsBetween(null, null, 0xFFFF); 73 + $this->assertEqual( 74 + mpull($expect, 'getISO8601'), 75 + mpull($result, 'getISO8601'), 76 + pht('Multiple date lists.')); 77 + 78 + $list_a = array( 79 + // This is Jan 1, 3, 5, 7, 8 and 10, but listed out-of-order. 80 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'), 81 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'), 82 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 83 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 84 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160110T120000Z'), 85 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160107T120000Z'), 86 + ); 87 + 88 + $list_b = array( 89 + // This is Jan 2, 4, 5, 8, but listed out of order. 90 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 91 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'), 92 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'), 93 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'), 94 + ); 95 + 96 + $list_c = array( 97 + // We're going to use this as an exception list. 98 + 99 + // This is Jan 7 (listed in one other source), 8 (listed in two) 100 + // and 9 (listed in none). 101 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160107T120000Z'), 102 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'), 103 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160109T120000Z'), 104 + ); 105 + 106 + $expect = array( 107 + // From source A. 108 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 109 + // From source B. 110 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 111 + // From source A. 112 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 113 + // From source B. 114 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'), 115 + // From source A and B. Should appear only once. 116 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'), 117 + // The 6th appears in no source. 118 + // The 7th, 8th and 9th are excluded. 119 + // The 10th is from source A. 120 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160110T120000Z'), 121 + ); 122 + 123 + $list_a = id(new PhutilCalendarRecurrenceList()) 124 + ->setDates($list_a); 125 + 126 + $list_b = id(new PhutilCalendarRecurrenceList()) 127 + ->setDates($list_b); 128 + 129 + $list_c = id(new PhutilCalendarRecurrenceList()) 130 + ->setDates($list_c) 131 + ->setIsExceptionSource(true); 132 + 133 + $date_set = id(new PhutilCalendarRecurrenceSet()) 134 + ->addSource($list_b) 135 + ->addSource($list_c) 136 + ->addSource($list_a); 137 + 138 + $date_set->setViewerTimezone('UTC'); 139 + 140 + $result = $date_set->getEventsBetween(null, null, 0xFFFF); 141 + $this->assertEqual( 142 + mpull($expect, 'getISO8601'), 143 + mpull($result, 'getISO8601'), 144 + pht('Set of all results in multiple lists with exclusions.')); 145 + 146 + 147 + $expect = array( 148 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 149 + ); 150 + $result = $date_set->getEventsBetween(null, null, 1); 151 + $this->assertEqual( 152 + mpull($expect, 'getISO8601'), 153 + mpull($result, 'getISO8601'), 154 + pht('Multiple lists, one result.')); 155 + 156 + $expect = array( 157 + 2 => PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 158 + 3 => PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'), 159 + ); 160 + $result = $date_set->getEventsBetween( 161 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 162 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z')); 163 + $this->assertEqual( 164 + mpull($expect, 'getISO8601'), 165 + mpull($result, 'getISO8601'), 166 + pht('Multiple lists, time window.')); 167 + } 168 + 169 + public function testCalendarRecurrenceOffsets() { 170 + $list = array( 171 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), 172 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), 173 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), 174 + ); 175 + 176 + $source = id(new PhutilCalendarRecurrenceList()) 177 + ->setDates($list); 178 + 179 + $set = id(new PhutilCalendarRecurrenceSet()) 180 + ->addSource($source); 181 + 182 + $t1 = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120001Z'); 183 + $t2 = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'); 184 + 185 + $expect = array( 186 + 2 => $t2, 187 + ); 188 + 189 + $result = $set->getEventsBetween($t1, null, 0xFFFF); 190 + $this->assertEqual( 191 + mpull($expect, 'getISO8601'), 192 + mpull($result, 'getISO8601'), 193 + pht('Correct event indexes with start date.')); 194 + } 195 + 196 + }
+919
src/applications/calendar/parser/ics/PhutilICSParser.php
··· 1 + <?php 2 + 3 + final class PhutilICSParser extends Phobject { 4 + 5 + private $stack; 6 + private $node; 7 + private $document; 8 + private $lines; 9 + private $cursor; 10 + 11 + private $warnings; 12 + 13 + const PARSE_MISSING_END = 'missing-end'; 14 + const PARSE_INITIAL_UNFOLD = 'initial-unfold'; 15 + const PARSE_UNEXPECTED_CHILD = 'unexpected-child'; 16 + const PARSE_EXTRA_END = 'extra-end'; 17 + const PARSE_MISMATCHED_SECTIONS = 'mismatched-sections'; 18 + const PARSE_ROOT_PROPERTY = 'root-property'; 19 + const PARSE_BAD_BASE64 = 'bad-base64'; 20 + const PARSE_BAD_BOOLEAN = 'bad-boolean'; 21 + const PARSE_UNEXPECTED_TEXT = 'unexpected-text'; 22 + const PARSE_MALFORMED_DOUBLE_QUOTE = 'malformed-double-quote'; 23 + const PARSE_MALFORMED_PARAMETER_NAME = 'malformed-parameter'; 24 + const PARSE_MALFORMED_PROPERTY = 'malformed-property'; 25 + const PARSE_MISSING_VALUE = 'missing-value'; 26 + const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash'; 27 + const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters'; 28 + const PARSE_EMPTY_DATETIME = 'empty-datetime'; 29 + const PARSE_MANY_DATETIME = 'many-datetime'; 30 + const PARSE_BAD_DATETIME = 'bad-datetime'; 31 + const PARSE_EMPTY_DURATION = 'empty-duration'; 32 + const PARSE_MANY_DURATION = 'many-duration'; 33 + const PARSE_BAD_DURATION = 'bad-duration'; 34 + 35 + const WARN_TZID_UTC = 'warn-tzid-utc'; 36 + const WARN_TZID_GUESS = 'warn-tzid-guess'; 37 + const WARN_TZID_IGNORED = 'warn-tzid-ignored'; 38 + 39 + public function parseICSData($data) { 40 + $this->stack = array(); 41 + $this->node = null; 42 + $this->cursor = null; 43 + $this->warnings = array(); 44 + 45 + $lines = $this->unfoldICSLines($data); 46 + $this->lines = $lines; 47 + 48 + $root = $this->newICSNode('<ROOT>'); 49 + $this->stack[] = $root; 50 + $this->node = $root; 51 + 52 + foreach ($lines as $key => $line) { 53 + $this->cursor = $key; 54 + $matches = null; 55 + if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) { 56 + $this->beginParsingNode($matches[1]); 57 + } else if (preg_match('(^END:(.*)\z)', $line, $matches)) { 58 + $this->endParsingNode($matches[1]); 59 + } else { 60 + if (count($this->stack) < 2) { 61 + $this->raiseParseFailure( 62 + self::PARSE_ROOT_PROPERTY, 63 + pht( 64 + 'Found unexpected property at ICS document root.')); 65 + } 66 + $this->parseICSProperty($line); 67 + } 68 + } 69 + 70 + if (count($this->stack) > 1) { 71 + $this->raiseParseFailure( 72 + self::PARSE_MISSING_END, 73 + pht( 74 + 'Expected all "BEGIN:" sections in ICS document to have '. 75 + 'corresponding "END:" sections.')); 76 + } 77 + 78 + $this->node = null; 79 + $this->lines = null; 80 + $this->cursor = null; 81 + 82 + return $root; 83 + } 84 + 85 + private function getNode() { 86 + return $this->node; 87 + } 88 + 89 + private function unfoldICSLines($data) { 90 + $lines = phutil_split_lines($data, $retain_endings = false); 91 + $this->lines = $lines; 92 + 93 + // ICS files are wrapped at 75 characters, with overlong lines continued 94 + // on the following line with an initial space or tab. Unwrap all of the 95 + // lines in the file. 96 + 97 + // This unwrapping is specifically byte-oriented, not character oriented, 98 + // and RFC5545 anticipates that simple implementations may even split UTF8 99 + // characters in the middle. 100 + 101 + $last = null; 102 + foreach ($lines as $idx => $line) { 103 + $this->cursor = $idx; 104 + if (!preg_match('/^[ \t]/', $line)) { 105 + $last = $idx; 106 + continue; 107 + } 108 + 109 + if ($last === null) { 110 + $this->raiseParseFailure( 111 + self::PARSE_INITIAL_UNFOLD, 112 + pht( 113 + 'First line of ICS file begins with a space or tab, but this '. 114 + 'marks a line which should be unfolded.')); 115 + } 116 + 117 + $lines[$last] = $lines[$last].substr($line, 1); 118 + unset($lines[$idx]); 119 + } 120 + 121 + return $lines; 122 + } 123 + 124 + private function beginParsingNode($type) { 125 + $node = $this->getNode(); 126 + $new_node = $this->newICSNode($type); 127 + 128 + if ($node instanceof PhutilCalendarContainerNode) { 129 + $node->appendChild($new_node); 130 + } else { 131 + $this->raiseParseFailure( 132 + self::PARSE_UNEXPECTED_CHILD, 133 + pht( 134 + 'Found unexpected node "%s" inside node "%s".', 135 + $new_node->getAttribute('ics.type'), 136 + $node->getAttribute('ics.type'))); 137 + } 138 + 139 + $this->stack[] = $new_node; 140 + $this->node = $new_node; 141 + 142 + return $this; 143 + } 144 + 145 + private function newICSNode($type) { 146 + switch ($type) { 147 + case '<ROOT>': 148 + $node = new PhutilCalendarRootNode(); 149 + break; 150 + case 'VCALENDAR': 151 + $node = new PhutilCalendarDocumentNode(); 152 + break; 153 + case 'VEVENT': 154 + $node = new PhutilCalendarEventNode(); 155 + break; 156 + default: 157 + $node = new PhutilCalendarRawNode(); 158 + break; 159 + } 160 + 161 + $node->setAttribute('ics.type', $type); 162 + 163 + return $node; 164 + } 165 + 166 + private function endParsingNode($type) { 167 + $node = $this->getNode(); 168 + if ($node instanceof PhutilCalendarRootNode) { 169 + $this->raiseParseFailure( 170 + self::PARSE_EXTRA_END, 171 + pht( 172 + 'Found unexpected "END" without a "BEGIN".')); 173 + } 174 + 175 + $old_type = $node->getAttribute('ics.type'); 176 + if ($old_type != $type) { 177 + $this->raiseParseFailure( 178 + self::PARSE_MISMATCHED_SECTIONS, 179 + pht( 180 + 'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.', 181 + $old_type, 182 + $type)); 183 + } 184 + 185 + array_pop($this->stack); 186 + $this->node = last($this->stack); 187 + 188 + return $this; 189 + } 190 + 191 + private function parseICSProperty($line) { 192 + $matches = null; 193 + 194 + // Properties begin with an alphanumeric name with no escaping, followed 195 + // by either a ";" (to begin a list of parameters) or a ":" (to begin 196 + // the actual field body). 197 + 198 + $ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches); 199 + if (!$ok) { 200 + $this->raiseParseFailure( 201 + self::PARSE_MALFORMED_PROPERTY, 202 + pht( 203 + 'Found malformed property in ICS document.')); 204 + } 205 + 206 + $name = $matches[1]; 207 + $body = $matches[3]; 208 + $has_parameters = ($matches[2] == ';'); 209 + 210 + $parameters = array(); 211 + if ($has_parameters) { 212 + // Parameters are a sensible name, a literal "=", a pile of magic, 213 + // and then maybe a comma and another parameter. 214 + 215 + while (true) { 216 + // We're going to get the first couple of parts first. 217 + $ok = preg_match('(^([^=]+)=)', $body, $matches); 218 + if (!$ok) { 219 + $this->raiseParseFailure( 220 + self::PARSE_MALFORMED_PARAMETER_NAME, 221 + pht( 222 + 'Found malformed property in ICS document: %s', 223 + $body)); 224 + } 225 + 226 + $param_name = $matches[1]; 227 + $body = substr($body, strlen($matches[0])); 228 + 229 + // Now we're going to match zero or more values. 230 + $param_values = array(); 231 + while (true) { 232 + // The value can either be a double-quoted string or an unquoted 233 + // string, with some characters forbidden. 234 + if (strlen($body) && $body[0] == '"') { 235 + $is_quoted = true; 236 + $ok = preg_match( 237 + '(^"([^\x00-\x08\x10-\x19"]*)")', 238 + $body, 239 + $matches); 240 + if (!$ok) { 241 + $this->raiseParseFailure( 242 + self::PARSE_MALFORMED_DOUBLE_QUOTE, 243 + pht( 244 + 'Found malformed double-quoted string in ICS document '. 245 + 'parameter value.')); 246 + } 247 + } else { 248 + $is_quoted = false; 249 + 250 + // It's impossible for this not to match since it can match 251 + // nothing, and it's valid for it to match nothing. 252 + preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches); 253 + } 254 + 255 + // NOTE: RFC5545 says "Property parameter values that are not in 256 + // quoted-strings are case-insensitive." -- that is, the quoted and 257 + // unquoted representations are not equivalent. Thus, preserve the 258 + // original formatting in case we ever need to respect this. 259 + 260 + $param_values[] = array( 261 + 'value' => $this->unescapeParameterValue($matches[1]), 262 + 'quoted' => $is_quoted, 263 + ); 264 + 265 + $body = substr($body, strlen($matches[0])); 266 + if (!strlen($body)) { 267 + $this->raiseParseFailure( 268 + self::PARSE_MISSING_VALUE, 269 + pht( 270 + 'Expected ":" after parameters in ICS document property.')); 271 + } 272 + 273 + // If we have a comma now, we're going to read another value. Strip 274 + // it off and keep going. 275 + if ($body[0] == ',') { 276 + $body = substr($body, 1); 277 + continue; 278 + } 279 + 280 + // If we have a semicolon, we're going to read another parameter. 281 + if ($body[0] == ';') { 282 + break; 283 + } 284 + 285 + // If we have a colon, this is the last value and also the last 286 + // property. Break, then handle the colon below. 287 + if ($body[0] == ':') { 288 + break; 289 + } 290 + 291 + $short_body = id(new PhutilUTF8StringTruncator()) 292 + ->setMaximumGlyphs(32) 293 + ->truncateString($body); 294 + 295 + // We aren't expecting anything else. 296 + $this->raiseParseFailure( 297 + self::PARSE_UNEXPECTED_TEXT, 298 + pht( 299 + 'Found unexpected text ("%s") after reading parameter value.', 300 + $short_body)); 301 + } 302 + 303 + $parameters[] = array( 304 + 'name' => $param_name, 305 + 'values' => $param_values, 306 + ); 307 + 308 + if ($body[0] == ';') { 309 + $body = substr($body, 1); 310 + continue; 311 + } 312 + 313 + if ($body[0] == ':') { 314 + $body = substr($body, 1); 315 + break; 316 + } 317 + } 318 + } 319 + 320 + $value = $this->unescapeFieldValue($name, $parameters, $body); 321 + 322 + $node = $this->getNode(); 323 + 324 + 325 + $raw = $node->getAttribute('ics.properties', array()); 326 + $raw[] = array( 327 + 'name' => $name, 328 + 'parameters' => $parameters, 329 + 'value' => $value, 330 + ); 331 + $node->setAttribute('ics.properties', $raw); 332 + 333 + switch ($node->getAttribute('ics.type')) { 334 + case 'VEVENT': 335 + $this->didParseEventProperty($node, $name, $parameters, $value); 336 + break; 337 + } 338 + } 339 + 340 + private function unescapeParameterValue($data) { 341 + // The parameter grammar is adjusted by RFC6868 to permit escaping with 342 + // carets. Remove that escaping. 343 + 344 + // This escaping is a bit weird because it's trying to be backwards 345 + // compatible and the original spec didn't think about this and didn't 346 + // provide much room to fix things. 347 + 348 + $out = ''; 349 + $esc = false; 350 + foreach (phutil_utf8v($data) as $c) { 351 + if (!$esc) { 352 + if ($c != '^') { 353 + $out .= $c; 354 + } else { 355 + $esc = true; 356 + } 357 + } else { 358 + switch ($c) { 359 + case 'n': 360 + $out .= "\n"; 361 + break; 362 + case '^': 363 + $out .= '^'; 364 + break; 365 + case "'": 366 + // NOTE: This is "<caret> <single quote>" being decoded into a 367 + // double quote! 368 + $out .= '"'; 369 + break; 370 + default: 371 + // NOTE: The caret is NOT an escape for any other characters. 372 + // This is a "MUST" requirement of RFC6868. 373 + $out .= '^'.$c; 374 + break; 375 + } 376 + } 377 + } 378 + 379 + // NOTE: Because caret on its own just means "caret" for backward 380 + // compatibility, we don't warn if we're still in escaped mode once we 381 + // reach the end of the string. 382 + 383 + return $out; 384 + } 385 + 386 + private function unescapeFieldValue($name, array $parameters, $data) { 387 + // NOTE: The encoding of the field value data is dependent on the field 388 + // name (which defines a default encoding) and the parameters (which may 389 + // include "VALUE", specifying a type of the data. 390 + 391 + $default_types = array( 392 + 'CALSCALE' => 'TEXT', 393 + 'METHOD' => 'TEXT', 394 + 'PRODID' => 'TEXT', 395 + 'VERSION' => 'TEXT', 396 + 397 + 'ATTACH' => 'URI', 398 + 'CATEGORIES' => 'TEXT', 399 + 'CLASS' => 'TEXT', 400 + 'COMMENT' => 'TEXT', 401 + 'DESCRIPTION' => 'TEXT', 402 + 403 + // TODO: The spec appears to contradict itself: it says that the value 404 + // type is FLOAT, but it also says that this property value is actually 405 + // two semicolon-separated values, which is not what FLOAT is defined as. 406 + 'GEO' => 'TEXT', 407 + 408 + 'LOCATION' => 'TEXT', 409 + 'PERCENT-COMPLETE' => 'INTEGER', 410 + 'PRIORITY' => 'INTEGER', 411 + 'RESOURCES' => 'TEXT', 412 + 'STATUS' => 'TEXT', 413 + 'SUMMARY' => 'TEXT', 414 + 415 + 'COMPLETED' => 'DATE-TIME', 416 + 'DTEND' => 'DATE-TIME', 417 + 'DUE' => 'DATE-TIME', 418 + 'DTSTART' => 'DATE-TIME', 419 + 'DURATION' => 'DURATION', 420 + 'FREEBUSY' => 'PERIOD', 421 + 'TRANSP' => 'TEXT', 422 + 423 + 'TZID' => 'TEXT', 424 + 'TZNAME' => 'TEXT', 425 + 'TZOFFSETFROM' => 'UTC-OFFSET', 426 + 'TZOFFSETTO' => 'UTC-OFFSET', 427 + 'TZURL' => 'URI', 428 + 429 + 'ATTENDEE' => 'CAL-ADDRESS', 430 + 'CONTACT' => 'TEXT', 431 + 'ORGANIZER' => 'CAL-ADDRESS', 432 + 'RECURRENCE-ID' => 'DATE-TIME', 433 + 'RELATED-TO' => 'TEXT', 434 + 'URL' => 'URI', 435 + 'UID' => 'TEXT', 436 + 'EXDATE' => 'DATE-TIME', 437 + 'RDATE' => 'DATE-TIME', 438 + 'RRULE' => 'RECUR', 439 + 440 + 'ACTION' => 'TEXT', 441 + 'REPEAT' => 'INTEGER', 442 + 'TRIGGER' => 'DURATION', 443 + 444 + 'CREATED' => 'DATE-TIME', 445 + 'DTSTAMP' => 'DATE-TIME', 446 + 'LAST-MODIFIED' => 'DATE-TIME', 447 + 'SEQUENCE' => 'INTEGER', 448 + 449 + 'REQUEST-STATUS' => 'TEXT', 450 + ); 451 + 452 + $value_type = idx($default_types, $name, 'TEXT'); 453 + 454 + foreach ($parameters as $parameter) { 455 + if ($parameter['name'] == 'VALUE') { 456 + $value_type = idx(head($parameter['values']), 'value'); 457 + } 458 + } 459 + 460 + switch ($value_type) { 461 + case 'BINARY': 462 + $result = base64_decode($data, true); 463 + if ($result === false) { 464 + $this->raiseParseFailure( 465 + self::PARSE_BAD_BASE64, 466 + pht( 467 + 'Unable to decode base64 data: %s', 468 + $data)); 469 + } 470 + break; 471 + case 'BOOLEAN': 472 + $map = array( 473 + 'true' => true, 474 + 'false' => false, 475 + ); 476 + $result = phutil_utf8_strtolower($data); 477 + if (!isset($map[$result])) { 478 + $this->raiseParseFailure( 479 + self::PARSE_BAD_BOOLEAN, 480 + pht( 481 + 'Unexpected BOOLEAN value "%s".', 482 + $data)); 483 + } 484 + $result = $map[$result]; 485 + break; 486 + case 'CAL-ADDRESS': 487 + $result = $data; 488 + break; 489 + case 'DATE': 490 + // This is a comma-separated list of "YYYYMMDD" values. 491 + $result = explode(',', $data); 492 + break; 493 + case 'DATE-TIME': 494 + if (!strlen($data)) { 495 + $result = array(); 496 + } else { 497 + $result = explode(',', $data); 498 + } 499 + break; 500 + case 'DURATION': 501 + if (!strlen($data)) { 502 + $result = array(); 503 + } else { 504 + $result = explode(',', $data); 505 + } 506 + break; 507 + case 'FLOAT': 508 + $result = explode(',', $data); 509 + foreach ($result as $k => $v) { 510 + $result[$k] = (float)$v; 511 + } 512 + break; 513 + case 'INTEGER': 514 + $result = explode(',', $data); 515 + foreach ($result as $k => $v) { 516 + $result[$k] = (int)$v; 517 + } 518 + break; 519 + case 'PERIOD': 520 + $result = explode(',', $data); 521 + break; 522 + case 'RECUR': 523 + $result = $data; 524 + break; 525 + case 'TEXT': 526 + $result = $this->unescapeTextValue($data); 527 + break; 528 + case 'TIME': 529 + $result = explode(',', $data); 530 + break; 531 + case 'URI': 532 + $result = $data; 533 + break; 534 + case 'UTC-OFFSET': 535 + $result = $data; 536 + break; 537 + default: 538 + // RFC5545 says we MUST preserve the data for any types we don't 539 + // recognize. 540 + $result = $data; 541 + break; 542 + } 543 + 544 + return array( 545 + 'type' => $value_type, 546 + 'value' => $result, 547 + 'raw' => $data, 548 + ); 549 + } 550 + 551 + private function unescapeTextValue($data) { 552 + $result = array(); 553 + 554 + $buf = ''; 555 + $esc = false; 556 + foreach (phutil_utf8v($data) as $c) { 557 + if (!$esc) { 558 + if ($c == '\\') { 559 + $esc = true; 560 + } else if ($c == ',') { 561 + $result[] = $buf; 562 + $buf = ''; 563 + } else { 564 + $buf .= $c; 565 + } 566 + } else { 567 + switch ($c) { 568 + case 'n': 569 + case 'N': 570 + $buf .= "\n"; 571 + break; 572 + default: 573 + $buf .= $c; 574 + break; 575 + } 576 + $esc = false; 577 + } 578 + } 579 + 580 + if ($esc) { 581 + $this->raiseParseFailure( 582 + self::PARSE_UNESCAPED_BACKSLASH, 583 + pht( 584 + 'ICS document contains TEXT value ending with unescaped '. 585 + 'backslash.')); 586 + } 587 + 588 + $result[] = $buf; 589 + 590 + return $result; 591 + } 592 + 593 + private function raiseParseFailure($code, $message) { 594 + if ($this->lines && isset($this->lines[$this->cursor])) { 595 + $message = pht( 596 + "ICS Parse Error near line %s:\n\n>>> %s\n\n%s", 597 + $this->cursor + 1, 598 + $this->lines[$this->cursor], 599 + $message); 600 + } else { 601 + $message = pht( 602 + 'ICS Parse Error: %s', 603 + $message); 604 + } 605 + 606 + throw id(new PhutilICSParserException($message)) 607 + ->setParserFailureCode($code); 608 + } 609 + 610 + private function raiseWarning($code, $message) { 611 + $this->warnings[] = array( 612 + 'code' => $code, 613 + 'line' => $this->cursor, 614 + 'text' => $this->lines[$this->cursor], 615 + 'message' => $message, 616 + ); 617 + 618 + return $this; 619 + } 620 + 621 + public function getWarnings() { 622 + return $this->warnings; 623 + } 624 + 625 + private function didParseEventProperty( 626 + PhutilCalendarEventNode $node, 627 + $name, 628 + array $parameters, 629 + array $value) { 630 + 631 + switch ($name) { 632 + case 'UID': 633 + $text = $this->newTextFromProperty($parameters, $value); 634 + $node->setUID($text); 635 + break; 636 + case 'CREATED': 637 + $datetime = $this->newDateTimeFromProperty($parameters, $value); 638 + $node->setCreatedDateTime($datetime); 639 + break; 640 + case 'DTSTAMP': 641 + $datetime = $this->newDateTimeFromProperty($parameters, $value); 642 + $node->setModifiedDateTime($datetime); 643 + break; 644 + case 'SUMMARY': 645 + $text = $this->newTextFromProperty($parameters, $value); 646 + $node->setName($text); 647 + break; 648 + case 'DESCRIPTION': 649 + $text = $this->newTextFromProperty($parameters, $value); 650 + $node->setDescription($text); 651 + break; 652 + case 'DTSTART': 653 + $datetime = $this->newDateTimeFromProperty($parameters, $value); 654 + $node->setStartDateTime($datetime); 655 + break; 656 + case 'DTEND': 657 + $datetime = $this->newDateTimeFromProperty($parameters, $value); 658 + $node->setEndDateTime($datetime); 659 + break; 660 + case 'DURATION': 661 + $duration = $this->newDurationFromProperty($parameters, $value); 662 + $node->setDuration($duration); 663 + break; 664 + case 'RRULE': 665 + $rrule = $this->newRecurrenceRuleFromProperty($parameters, $value); 666 + $node->setRecurrenceRule($rrule); 667 + break; 668 + case 'RECURRENCE-ID': 669 + $text = $this->newTextFromProperty($parameters, $value); 670 + $node->setRecurrenceID($text); 671 + break; 672 + case 'ATTENDEE': 673 + $attendee = $this->newAttendeeFromProperty($parameters, $value); 674 + $node->addAttendee($attendee); 675 + break; 676 + } 677 + 678 + } 679 + 680 + private function newTextFromProperty(array $parameters, array $value) { 681 + $value = $value['value']; 682 + return implode("\n\n", $value); 683 + } 684 + 685 + private function newAttendeeFromProperty(array $parameters, array $value) { 686 + $uri = $value['value']; 687 + 688 + switch (idx($parameters, 'PARTSTAT')) { 689 + case 'ACCEPTED': 690 + $status = PhutilCalendarUserNode::STATUS_ACCEPTED; 691 + break; 692 + case 'DECLINED': 693 + $status = PhutilCalendarUserNode::STATUS_DECLINED; 694 + break; 695 + case 'NEEDS-ACTION': 696 + default: 697 + $status = PhutilCalendarUserNode::STATUS_INVITED; 698 + break; 699 + } 700 + 701 + $name = $this->getScalarParameterValue($parameters, 'CN'); 702 + 703 + return id(new PhutilCalendarUserNode()) 704 + ->setURI($uri) 705 + ->setName($name) 706 + ->setStatus($status); 707 + } 708 + 709 + private function newDateTimeFromProperty(array $parameters, array $value) { 710 + $value = $value['value']; 711 + 712 + if (!$value) { 713 + $this->raiseParseFailure( 714 + self::PARSE_EMPTY_DATETIME, 715 + pht( 716 + 'Expected DATE-TIME to have exactly one value, found none.')); 717 + 718 + } 719 + 720 + if (count($value) > 1) { 721 + $this->raiseParseFailure( 722 + self::PARSE_MANY_DATETIME, 723 + pht( 724 + 'Expected DATE-TIME to have exactly one value, found more than '. 725 + 'one.')); 726 + } 727 + 728 + $value = head($value); 729 + $tzid = $this->getScalarParameterValue($parameters, 'TZID'); 730 + 731 + if (preg_match('/Z\z/', $value)) { 732 + if ($tzid) { 733 + $this->raiseWarning( 734 + self::WARN_TZID_UTC, 735 + pht( 736 + 'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '. 737 + 'parameter with value "%s". This violates RFC5545. The TZID '. 738 + 'will be ignored, and the value will be interpreted as UTC.', 739 + $value, 740 + $tzid)); 741 + } 742 + $tzid = 'UTC'; 743 + } else if ($tzid !== null) { 744 + $tzid = $this->guessTimezone($tzid); 745 + } 746 + 747 + try { 748 + $datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601( 749 + $value, 750 + $tzid); 751 + } catch (Exception $ex) { 752 + $this->raiseParseFailure( 753 + self::PARSE_BAD_DATETIME, 754 + pht( 755 + 'Error parsing DATE-TIME: %s', 756 + $ex->getMessage())); 757 + } 758 + 759 + return $datetime; 760 + } 761 + 762 + private function newDurationFromProperty(array $parameters, array $value) { 763 + $value = $value['value']; 764 + 765 + if (!$value) { 766 + $this->raiseParseFailure( 767 + self::PARSE_EMPTY_DURATION, 768 + pht( 769 + 'Expected DURATION to have exactly one value, found none.')); 770 + 771 + } 772 + 773 + if (count($value) > 1) { 774 + $this->raiseParseFailure( 775 + self::PARSE_MANY_DURATION, 776 + pht( 777 + 'Expected DURATION to have exactly one value, found more than '. 778 + 'one.')); 779 + } 780 + 781 + $value = head($value); 782 + 783 + try { 784 + $duration = PhutilCalendarDuration::newFromISO8601($value); 785 + } catch (Exception $ex) { 786 + $this->raiseParseFailure( 787 + self::PARSE_BAD_DURATION, 788 + pht( 789 + 'Invalid DURATION: %s', 790 + $ex->getMessage())); 791 + } 792 + 793 + return $duration; 794 + } 795 + 796 + private function newRecurrenceRuleFromProperty(array $parameters, $value) { 797 + return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']); 798 + } 799 + 800 + private function getScalarParameterValue( 801 + array $parameters, 802 + $name, 803 + $default = null) { 804 + 805 + $match = null; 806 + foreach ($parameters as $parameter) { 807 + if ($parameter['name'] == $name) { 808 + $match = $parameter; 809 + } 810 + } 811 + 812 + if ($match === null) { 813 + return $default; 814 + } 815 + 816 + $value = $match['values']; 817 + if (!$value) { 818 + // Parameter is specified, but with no value, like "KEY=". Just return 819 + // the default, as though the parameter was not specified. 820 + return $default; 821 + } 822 + 823 + if (count($value) > 1) { 824 + $this->raiseParseFailure( 825 + self::PARSE_MULTIPLE_PARAMETERS, 826 + pht( 827 + 'Expected parameter "%s" to have at most one value, but found '. 828 + 'more than one.', 829 + $name)); 830 + } 831 + 832 + return idx(head($value), 'value'); 833 + } 834 + 835 + private function guessTimezone($tzid) { 836 + $map = DateTimeZone::listIdentifiers(); 837 + $map = array_fuse($map); 838 + if (isset($map[$tzid])) { 839 + // This is a real timezone we recognize, so just use it as provided. 840 + return $tzid; 841 + } 842 + 843 + // These are alternate names for timezones. 844 + static $aliases; 845 + 846 + if ($aliases === null) { 847 + $aliases = array( 848 + 'Etc/GMT' => 'UTC', 849 + ); 850 + 851 + // Load the map of Windows timezones. 852 + $root_path = dirname(phutil_get_library_root('phutil')); 853 + $windows_path = $root_path.'/resources/timezones/windows_timezones.json'; 854 + $windows_data = Filesystem::readFile($windows_path); 855 + $windows_zones = phutil_json_decode($windows_data); 856 + 857 + $aliases = $aliases + $windows_zones; 858 + } 859 + 860 + if (isset($aliases[$tzid])) { 861 + return $aliases[$tzid]; 862 + } 863 + 864 + // Look for something that looks like "UTC+3" or "GMT -05.00". If we find 865 + // anything, pick a timezone with that offset. 866 + $offset_pattern = 867 + '/'. 868 + '(?:UTC|GMT)'. 869 + '\s*'. 870 + '(?P<sign>[+-])'. 871 + '\s*'. 872 + '(?P<h>\d+)'. 873 + '(?:'. 874 + '[:.](?P<m>\d+)'. 875 + ')?'. 876 + '/i'; 877 + 878 + $matches = null; 879 + if (preg_match($offset_pattern, $tzid, $matches)) { 880 + $hours = (int)$matches['h']; 881 + $minutes = (int)idx($matches, 'm'); 882 + $offset = ($hours * 60 * 60) + ($minutes * 60); 883 + 884 + if (idx($matches, 'sign') == '-') { 885 + $offset = -$offset; 886 + } 887 + 888 + // NOTE: We could possibly do better than this, by using the event start 889 + // time to guess a timezone. However, that won't work for recurring 890 + // events and would require us to do this work after finishing initial 891 + // parsing. Since these unusual offset-based timezones appear to be rare, 892 + // the benefit may not be worth the complexity. 893 + $now = new DateTime('@'.time()); 894 + 895 + foreach ($map as $identifier) { 896 + $zone = new DateTimeZone($identifier); 897 + if ($zone->getOffset($now) == $offset) { 898 + $this->raiseWarning( 899 + self::WARN_TZID_GUESS, 900 + pht( 901 + 'TZID "%s" is unknown, guessing "%s" based on pattern "%s".', 902 + $tzid, 903 + $identifier, 904 + $matches[0])); 905 + return $identifier; 906 + } 907 + } 908 + } 909 + 910 + $this->raiseWarning( 911 + self::WARN_TZID_IGNORED, 912 + pht( 913 + 'TZID "%s" is unknown, using UTC instead.', 914 + $tzid)); 915 + 916 + return 'UTC'; 917 + } 918 + 919 + }
+16
src/applications/calendar/parser/ics/PhutilICSParserException.php
··· 1 + <?php 2 + 3 + final class PhutilICSParserException extends Exception { 4 + 5 + private $parserFailureCode; 6 + 7 + public function setParserFailureCode($code) { 8 + $this->parserFailureCode = $code; 9 + return $this; 10 + } 11 + 12 + public function getParserFailureCode() { 13 + return $this->parserFailureCode; 14 + } 15 + 16 + }
+387
src/applications/calendar/parser/ics/PhutilICSWriter.php
··· 1 + <?php 2 + 3 + final class PhutilICSWriter extends Phobject { 4 + 5 + public function writeICSDocument(PhutilCalendarRootNode $node) { 6 + $out = array(); 7 + 8 + foreach ($node->getChildren() as $child) { 9 + $out[] = $this->writeNode($child); 10 + } 11 + 12 + return implode('', $out); 13 + } 14 + 15 + private function writeNode(PhutilCalendarNode $node) { 16 + if (!$this->getICSNodeType($node)) { 17 + return null; 18 + } 19 + 20 + $out = array(); 21 + 22 + $out[] = $this->writeBeginNode($node); 23 + $out[] = $this->writeNodeProperties($node); 24 + 25 + if ($node instanceof PhutilCalendarContainerNode) { 26 + foreach ($node->getChildren() as $child) { 27 + $out[] = $this->writeNode($child); 28 + } 29 + } 30 + 31 + $out[] = $this->writeEndNode($node); 32 + 33 + return implode('', $out); 34 + } 35 + 36 + private function writeBeginNode(PhutilCalendarNode $node) { 37 + $type = $this->getICSNodeType($node); 38 + return $this->wrapICSLine("BEGIN:{$type}"); 39 + } 40 + 41 + private function writeEndNode(PhutilCalendarNode $node) { 42 + $type = $this->getICSNodeType($node); 43 + return $this->wrapICSLine("END:{$type}"); 44 + } 45 + 46 + private function writeNodeProperties(PhutilCalendarNode $node) { 47 + $properties = $this->getNodeProperties($node); 48 + 49 + $out = array(); 50 + foreach ($properties as $property) { 51 + $propname = $property['name']; 52 + $propvalue = $property['value']; 53 + 54 + $propline = array(); 55 + $propline[] = $propname; 56 + 57 + foreach ($property['parameters'] as $parameter) { 58 + $paramname = $parameter['name']; 59 + $paramvalue = $parameter['value']; 60 + $propline[] = ";{$paramname}={$paramvalue}"; 61 + } 62 + 63 + $propline[] = ":{$propvalue}"; 64 + $propline = implode('', $propline); 65 + 66 + $out[] = $this->wrapICSLine($propline); 67 + } 68 + 69 + return implode('', $out); 70 + } 71 + 72 + private function getICSNodeType(PhutilCalendarNode $node) { 73 + switch ($node->getNodeType()) { 74 + case PhutilCalendarDocumentNode::NODETYPE: 75 + return 'VCALENDAR'; 76 + case PhutilCalendarEventNode::NODETYPE: 77 + return 'VEVENT'; 78 + default: 79 + return null; 80 + } 81 + } 82 + 83 + private function wrapICSLine($line) { 84 + $out = array(); 85 + $buf = ''; 86 + 87 + // NOTE: The line may contain sequences of combining characters which are 88 + // more than 80 bytes in length. If it does, we'll split them in the 89 + // middle of the sequence. This is okay and generally anticipated by 90 + // RFC5545, which even allows implementations to split multibyte 91 + // characters. The sequence will be stitched back together properly by 92 + // whatever is parsing things. 93 + 94 + foreach (phutil_utf8v($line) as $character) { 95 + // If adding this character would bring the line over 75 bytes, start 96 + // a new line. 97 + if (strlen($buf) + strlen($character) > 75) { 98 + $out[] = $buf."\r\n"; 99 + $buf = ' '; 100 + } 101 + 102 + $buf .= $character; 103 + } 104 + 105 + $out[] = $buf."\r\n"; 106 + 107 + return implode('', $out); 108 + } 109 + 110 + private function getNodeProperties(PhutilCalendarNode $node) { 111 + switch ($node->getNodeType()) { 112 + case PhutilCalendarDocumentNode::NODETYPE: 113 + return $this->getDocumentNodeProperties($node); 114 + case PhutilCalendarEventNode::NODETYPE: 115 + return $this->getEventNodeProperties($node); 116 + default: 117 + return array(); 118 + } 119 + } 120 + 121 + private function getDocumentNodeProperties( 122 + PhutilCalendarDocumentNode $event) { 123 + $properties = array(); 124 + 125 + $properties[] = $this->newTextProperty( 126 + 'VERSION', 127 + '2.0'); 128 + 129 + $properties[] = $this->newTextProperty( 130 + 'PRODID', 131 + '-//Phacility//Phabricator//EN'); 132 + 133 + return $properties; 134 + } 135 + 136 + private function getEventNodeProperties(PhutilCalendarEventNode $event) { 137 + $properties = array(); 138 + 139 + $uid = $event->getUID(); 140 + if (!strlen($uid)) { 141 + throw new Exception( 142 + pht( 143 + 'Unable to write ICS document: event has no UID, but each event '. 144 + 'MUST have a UID.')); 145 + } 146 + $properties[] = $this->newTextProperty( 147 + 'UID', 148 + $uid); 149 + 150 + $created = $event->getCreatedDateTime(); 151 + if ($created) { 152 + $properties[] = $this->newDateTimeProperty( 153 + 'CREATED', 154 + $event->getCreatedDateTime()); 155 + } 156 + 157 + $dtstamp = $event->getModifiedDateTime(); 158 + if (!$dtstamp) { 159 + throw new Exception( 160 + pht( 161 + 'Unable to write ICS document: event has no modified time, but '. 162 + 'each event MUST have a modified time.')); 163 + } 164 + $properties[] = $this->newDateTimeProperty( 165 + 'DTSTAMP', 166 + $dtstamp); 167 + 168 + $dtstart = $event->getStartDateTime(); 169 + if ($dtstart) { 170 + $properties[] = $this->newDateTimeProperty( 171 + 'DTSTART', 172 + $dtstart); 173 + } 174 + 175 + $dtend = $event->getEndDateTime(); 176 + if ($dtend) { 177 + $properties[] = $this->newDateTimeProperty( 178 + 'DTEND', 179 + $event->getEndDateTime()); 180 + } 181 + 182 + $name = $event->getName(); 183 + if (strlen($name)) { 184 + $properties[] = $this->newTextProperty( 185 + 'SUMMARY', 186 + $name); 187 + } 188 + 189 + $description = $event->getDescription(); 190 + if (strlen($description)) { 191 + $properties[] = $this->newTextProperty( 192 + 'DESCRIPTION', 193 + $description); 194 + } 195 + 196 + $organizer = $event->getOrganizer(); 197 + if ($organizer) { 198 + $properties[] = $this->newUserProperty( 199 + 'ORGANIZER', 200 + $organizer); 201 + } 202 + 203 + $attendees = $event->getAttendees(); 204 + if ($attendees) { 205 + foreach ($attendees as $attendee) { 206 + $properties[] = $this->newUserProperty( 207 + 'ATTENDEE', 208 + $attendee); 209 + } 210 + } 211 + 212 + $rrule = $event->getRecurrenceRule(); 213 + if ($rrule) { 214 + $properties[] = $this->newRRULEProperty( 215 + 'RRULE', 216 + $rrule); 217 + } 218 + 219 + $recurrence_id = $event->getRecurrenceID(); 220 + if ($recurrence_id) { 221 + $properties[] = $this->newTextProperty( 222 + 'RECURRENCE-ID', 223 + $recurrence_id); 224 + } 225 + 226 + $exdates = $event->getRecurrenceExceptions(); 227 + if ($exdates) { 228 + $properties[] = $this->newDateTimesProperty( 229 + 'EXDATE', 230 + $exdates); 231 + } 232 + 233 + $rdates = $event->getRecurrenceDates(); 234 + if ($rdates) { 235 + $properties[] = $this->newDateTimesProperty( 236 + 'RDATE', 237 + $rdates); 238 + } 239 + 240 + return $properties; 241 + } 242 + 243 + private function newTextProperty( 244 + $name, 245 + $value, 246 + array $parameters = array()) { 247 + 248 + $map = array( 249 + '\\' => '\\\\', 250 + ',' => '\\,', 251 + "\n" => '\\n', 252 + ); 253 + 254 + $value = (array)$value; 255 + foreach ($value as $k => $v) { 256 + $v = str_replace(array_keys($map), array_values($map), $v); 257 + $value[$k] = $v; 258 + } 259 + 260 + $value = implode(',', $value); 261 + 262 + return $this->newProperty($name, $value, $parameters); 263 + } 264 + 265 + private function newDateTimeProperty( 266 + $name, 267 + PhutilCalendarDateTime $value, 268 + array $parameters = array()) { 269 + 270 + return $this->newDateTimesProperty($name, array($value), $parameters); 271 + } 272 + 273 + private function newDateTimesProperty( 274 + $name, 275 + array $values, 276 + array $parameters = array()) { 277 + assert_instances_of($values, 'PhutilCalendarDateTime'); 278 + 279 + if (head($values)->getIsAllDay()) { 280 + $parameters[] = array( 281 + 'name' => 'VALUE', 282 + 'values' => array( 283 + 'DATE', 284 + ), 285 + ); 286 + } 287 + 288 + $datetimes = array(); 289 + foreach ($values as $value) { 290 + $datetimes[] = $value->getISO8601(); 291 + } 292 + $datetimes = implode(';', $datetimes); 293 + 294 + return $this->newProperty($name, $datetimes, $parameters); 295 + } 296 + 297 + private function newUserProperty( 298 + $name, 299 + PhutilCalendarUserNode $value, 300 + array $parameters = array()) { 301 + 302 + $parameters[] = array( 303 + 'name' => 'CN', 304 + 'values' => array( 305 + $value->getName(), 306 + ), 307 + ); 308 + 309 + $partstat = null; 310 + switch ($value->getStatus()) { 311 + case PhutilCalendarUserNode::STATUS_INVITED: 312 + $partstat = 'NEEDS-ACTION'; 313 + break; 314 + case PhutilCalendarUserNode::STATUS_ACCEPTED: 315 + $partstat = 'ACCEPTED'; 316 + break; 317 + case PhutilCalendarUserNode::STATUS_DECLINED: 318 + $partstat = 'DECLINED'; 319 + break; 320 + } 321 + 322 + if ($partstat !== null) { 323 + $parameters[] = array( 324 + 'name' => 'PARTSTAT', 325 + 'values' => array( 326 + $partstat, 327 + ), 328 + ); 329 + } 330 + 331 + // TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it 332 + // isn't clear if these are important to external programs or not. 333 + 334 + return $this->newProperty($name, $value->getURI(), $parameters); 335 + } 336 + 337 + private function newRRULEProperty( 338 + $name, 339 + PhutilCalendarRecurrenceRule $rule, 340 + array $parameters = array()) { 341 + 342 + $value = $rule->toRRULE(); 343 + return $this->newProperty($name, $value, $parameters); 344 + } 345 + 346 + private function newProperty( 347 + $name, 348 + $value, 349 + array $parameters = array()) { 350 + 351 + $map = array( 352 + '^' => '^^', 353 + "\n" => '^n', 354 + '"' => "^'", 355 + ); 356 + 357 + $writable_params = array(); 358 + foreach ($parameters as $k => $parameter) { 359 + $value_list = array(); 360 + foreach ($parameter['values'] as $v) { 361 + $v = str_replace(array_keys($map), array_values($map), $v); 362 + 363 + // If the parameter value isn't a very simple one, quote it. 364 + 365 + // RFC5545 says that we MUST quote it if it has a colon, a semicolon, 366 + // or a comma, and that we MUST quote it if it's a URI. 367 + if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) { 368 + $v = '"'.$v.'"'; 369 + } 370 + 371 + $value_list[] = $v; 372 + } 373 + 374 + $writable_params[] = array( 375 + 'name' => $parameter['name'], 376 + 'value' => implode(',', $value_list), 377 + ); 378 + } 379 + 380 + return array( 381 + 'name' => $name, 382 + 'value' => $value, 383 + 'parameters' => $writable_params, 384 + ); 385 + } 386 + 387 + }
+341
src/applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php
··· 1 + <?php 2 + 3 + final class PhutilICSParserTestCase extends PhutilTestCase { 4 + 5 + public function testICSParser() { 6 + $event = $this->parseICSSingleEvent('simple.ics'); 7 + 8 + $this->assertEqual( 9 + array( 10 + array( 11 + 'name' => 'CREATED', 12 + 'parameters' => array(), 13 + 'value' => array( 14 + 'type' => 'DATE-TIME', 15 + 'value' => array( 16 + '20160908T172702Z', 17 + ), 18 + 'raw' => '20160908T172702Z', 19 + ), 20 + ), 21 + array( 22 + 'name' => 'UID', 23 + 'parameters' => array(), 24 + 'value' => array( 25 + 'type' => 'TEXT', 26 + 'value' => array( 27 + '1CEB57AF-0C9C-402D-B3BD-D75BD4843F68', 28 + ), 29 + 'raw' => '1CEB57AF-0C9C-402D-B3BD-D75BD4843F68', 30 + ), 31 + ), 32 + array( 33 + 'name' => 'DTSTART', 34 + 'parameters' => array( 35 + array( 36 + 'name' => 'TZID', 37 + 'values' => array( 38 + array( 39 + 'value' => 'America/Los_Angeles', 40 + 'quoted' => false, 41 + ), 42 + ), 43 + ), 44 + ), 45 + 'value' => array( 46 + 'type' => 'DATE-TIME', 47 + 'value' => array( 48 + '20160915T090000', 49 + ), 50 + 'raw' => '20160915T090000', 51 + ), 52 + ), 53 + array( 54 + 'name' => 'DTEND', 55 + 'parameters' => array( 56 + array( 57 + 'name' => 'TZID', 58 + 'values' => array( 59 + array( 60 + 'value' => 'America/Los_Angeles', 61 + 'quoted' => false, 62 + ), 63 + ), 64 + ), 65 + ), 66 + 'value' => array( 67 + 'type' => 'DATE-TIME', 68 + 'value' => array( 69 + '20160915T100000', 70 + ), 71 + 'raw' => '20160915T100000', 72 + ), 73 + ), 74 + array( 75 + 'name' => 'SUMMARY', 76 + 'parameters' => array(), 77 + 'value' => array( 78 + 'type' => 'TEXT', 79 + 'value' => array( 80 + 'Simple Event', 81 + ), 82 + 'raw' => 'Simple Event', 83 + ), 84 + ), 85 + array( 86 + 'name' => 'DESCRIPTION', 87 + 'parameters' => array(), 88 + 'value' => array( 89 + 'type' => 'TEXT', 90 + 'value' => array( 91 + 'This is a simple event.', 92 + ), 93 + 'raw' => 'This is a simple event.', 94 + ), 95 + ), 96 + ), 97 + $event->getAttribute('ics.properties')); 98 + 99 + $this->assertEqual( 100 + 'Simple Event', 101 + $event->getName()); 102 + 103 + $this->assertEqual( 104 + 'This is a simple event.', 105 + $event->getDescription()); 106 + 107 + $this->assertEqual( 108 + 1473955200, 109 + $event->getStartDateTime()->getEpoch()); 110 + 111 + $this->assertEqual( 112 + 1473955200 + phutil_units('1 hour in seconds'), 113 + $event->getEndDateTime()->getEpoch()); 114 + } 115 + 116 + public function testICSOddTimezone() { 117 + $event = $this->parseICSSingleEvent('zimbra-timezone.ics'); 118 + 119 + $start = $event->getStartDateTime(); 120 + 121 + $this->assertEqual( 122 + '20170303T140000Z', 123 + $start->getISO8601()); 124 + } 125 + 126 + public function testICSFloatingTime() { 127 + // This tests "floating" event times, which have no absolute time and are 128 + // supposed to be interpreted using the viewer's timezone. It also uses 129 + // a duration, and the duration needs to float along with the viewer 130 + // timezone. 131 + 132 + $event = $this->parseICSSingleEvent('floating.ics'); 133 + 134 + $start = $event->getStartDateTime(); 135 + 136 + $caught = null; 137 + try { 138 + $start->getEpoch(); 139 + } catch (Exception $ex) { 140 + $caught = $ex; 141 + } 142 + 143 + $this->assertTrue( 144 + ($caught instanceof Exception), 145 + pht('Expected exception for floating time with no viewer timezone.')); 146 + 147 + $newyears_utc = strtotime('2015-01-01 00:00:00 UTC'); 148 + $this->assertEqual(1420070400, $newyears_utc); 149 + 150 + $start->setViewerTimezone('UTC'); 151 + $this->assertEqual( 152 + $newyears_utc, 153 + $start->getEpoch()); 154 + 155 + $start->setViewerTimezone('America/Los_Angeles'); 156 + $this->assertEqual( 157 + $newyears_utc + phutil_units('8 hours in seconds'), 158 + $start->getEpoch()); 159 + 160 + $start->setViewerTimezone('America/New_York'); 161 + $this->assertEqual( 162 + $newyears_utc + phutil_units('5 hours in seconds'), 163 + $start->getEpoch()); 164 + 165 + $end = $event->getEndDateTime(); 166 + $end->setViewerTimezone('UTC'); 167 + $this->assertEqual( 168 + $newyears_utc + phutil_units('24 hours in seconds'), 169 + $end->getEpoch()); 170 + 171 + $end->setViewerTimezone('America/Los_Angeles'); 172 + $this->assertEqual( 173 + $newyears_utc + phutil_units('32 hours in seconds'), 174 + $end->getEpoch()); 175 + 176 + $end->setViewerTimezone('America/New_York'); 177 + $this->assertEqual( 178 + $newyears_utc + phutil_units('29 hours in seconds'), 179 + $end->getEpoch()); 180 + } 181 + 182 + public function testICSVALARM() { 183 + $event = $this->parseICSSingleEvent('valarm.ics'); 184 + 185 + // For now, we parse but ignore VALARM sections. This test just makes 186 + // sure they survive parsing. 187 + 188 + $start_epoch = strtotime('2016-10-19 22:00:00 UTC'); 189 + $this->assertEqual(1476914400, $start_epoch); 190 + 191 + $this->assertEqual( 192 + $start_epoch, 193 + $event->getStartDateTime()->getEpoch()); 194 + } 195 + 196 + public function testICSDuration() { 197 + $event = $this->parseICSSingleEvent('duration.ics'); 198 + 199 + // Raw value is "20160719T095722Z". 200 + $start_epoch = strtotime('2016-07-19 09:57:22 UTC'); 201 + $this->assertEqual(1468922242, $start_epoch); 202 + 203 + // Raw value is "P1DT17H4M23S". 204 + $duration = 205 + phutil_units('1 day in seconds') + 206 + phutil_units('17 hours in seconds') + 207 + phutil_units('4 minutes in seconds') + 208 + phutil_units('23 seconds in seconds'); 209 + 210 + $this->assertEqual( 211 + $start_epoch, 212 + $event->getStartDateTime()->getEpoch()); 213 + 214 + $this->assertEqual( 215 + $start_epoch + $duration, 216 + $event->getEndDateTime()->getEpoch()); 217 + } 218 + 219 + public function testICSWeeklyEvent() { 220 + $event = $this->parseICSSingleEvent('weekly.ics'); 221 + 222 + $start = $event->getStartDateTime(); 223 + $start->setViewerTimezone('UTC'); 224 + 225 + $rrule = $event->getRecurrenceRule() 226 + ->setStartDateTime($start); 227 + 228 + $rset = id(new PhutilCalendarRecurrenceSet()) 229 + ->addSource($rrule); 230 + 231 + $result = $rset->getEventsBetween(null, null, 3); 232 + 233 + $expect = array( 234 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20150811'), 235 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20150818'), 236 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20150825'), 237 + ); 238 + 239 + $this->assertEqual( 240 + mpull($expect, 'getISO8601'), 241 + mpull($result, 'getISO8601'), 242 + pht('Weekly recurring event.')); 243 + } 244 + 245 + public function testICSParserErrors() { 246 + $map = array( 247 + 'err-missing-end.ics' => PhutilICSParser::PARSE_MISSING_END, 248 + 'err-bad-base64.ics' => PhutilICSParser::PARSE_BAD_BASE64, 249 + 'err-bad-boolean.ics' => PhutilICSParser::PARSE_BAD_BOOLEAN, 250 + 'err-extra-end.ics' => PhutilICSParser::PARSE_EXTRA_END, 251 + 'err-initial-unfold.ics' => PhutilICSParser::PARSE_INITIAL_UNFOLD, 252 + 'err-malformed-double-quote.ics' => 253 + PhutilICSParser::PARSE_MALFORMED_DOUBLE_QUOTE, 254 + 'err-malformed-parameter.ics' => 255 + PhutilICSParser::PARSE_MALFORMED_PARAMETER_NAME, 256 + 'err-malformed-property.ics' => 257 + PhutilICSParser::PARSE_MALFORMED_PROPERTY, 258 + 'err-missing-value.ics' => PhutilICSParser::PARSE_MISSING_VALUE, 259 + 'err-mixmatched-sections.ics' => 260 + PhutilICSParser::PARSE_MISMATCHED_SECTIONS, 261 + 'err-root-property.ics' => PhutilICSParser::PARSE_ROOT_PROPERTY, 262 + 'err-unescaped-backslash.ics' => 263 + PhutilICSParser::PARSE_UNESCAPED_BACKSLASH, 264 + 'err-unexpected-text.ics' => PhutilICSParser::PARSE_UNEXPECTED_TEXT, 265 + 'err-multiple-parameters.ics' => 266 + PhutilICSParser::PARSE_MULTIPLE_PARAMETERS, 267 + 'err-empty-datetime.ics' => 268 + PhutilICSParser::PARSE_EMPTY_DATETIME, 269 + 'err-many-datetime.ics' => 270 + PhutilICSParser::PARSE_MANY_DATETIME, 271 + 'err-bad-datetime.ics' => 272 + PhutilICSParser::PARSE_BAD_DATETIME, 273 + 'err-empty-duration.ics' => 274 + PhutilICSParser::PARSE_EMPTY_DURATION, 275 + 'err-many-duration.ics' => 276 + PhutilICSParser::PARSE_MANY_DURATION, 277 + 'err-bad-duration.ics' => 278 + PhutilICSParser::PARSE_BAD_DURATION, 279 + 280 + 'simple.ics' => null, 281 + 'good-boolean.ics' => null, 282 + 'multiple-vcalendars.ics' => null, 283 + ); 284 + 285 + foreach ($map as $test_file => $expect) { 286 + $caught = null; 287 + try { 288 + $this->parseICSDocument($test_file); 289 + } catch (PhutilICSParserException $ex) { 290 + $caught = $ex; 291 + } 292 + 293 + if ($expect === null) { 294 + $this->assertTrue( 295 + ($caught === null), 296 + pht( 297 + 'Expected no exception parsing "%s", got: %s', 298 + $test_file, 299 + (string)$ex)); 300 + } else { 301 + if ($caught) { 302 + $code = $ex->getParserFailureCode(); 303 + $explain = pht( 304 + 'Expected one exception parsing "%s", got a different '. 305 + 'one: %s', 306 + $test_file, 307 + (string)$ex); 308 + } else { 309 + $code = null; 310 + $explain = pht( 311 + 'Expected exception parsing "%s", got none.', 312 + $test_file); 313 + } 314 + 315 + $this->assertEqual($expect, $code, $explain); 316 + } 317 + } 318 + } 319 + 320 + private function parseICSSingleEvent($name) { 321 + $root = $this->parseICSDocument($name); 322 + 323 + $documents = $root->getDocuments(); 324 + $this->assertEqual(1, count($documents)); 325 + $document = head($documents); 326 + 327 + $events = $document->getEvents(); 328 + $this->assertEqual(1, count($events)); 329 + 330 + return head($events); 331 + } 332 + 333 + private function parseICSDocument($name) { 334 + $path = dirname(__FILE__).'/data/'.$name; 335 + $data = Filesystem::readFile($path); 336 + return id(new PhutilICSParser()) 337 + ->parseICSData($data); 338 + } 339 + 340 + 341 + }
+144
src/applications/calendar/parser/ics/__tests__/PhutilICSWriterTestCase.php
··· 1 + <?php 2 + 3 + final class PhutilICSWriterTestCase extends PhutilTestCase { 4 + 5 + public function testICSWriterTeaTime() { 6 + $teas = array( 7 + 'earl grey tea', 8 + 'English breakfast tea', 9 + 'black tea', 10 + 'green tea', 11 + 't-rex', 12 + 'oolong tea', 13 + 'mint tea', 14 + 'tea with milk', 15 + ); 16 + 17 + $teas = implode(', ', $teas); 18 + 19 + $event = id(new PhutilCalendarEventNode()) 20 + ->setUID('tea-time') 21 + ->setName('Tea Time') 22 + ->setDescription( 23 + "Tea and, perhaps, crumpets.\n". 24 + "Your presence is requested!\n". 25 + "This is a long list of types of tea to test line wrapping: {$teas}.") 26 + ->setCreatedDateTime( 27 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z')) 28 + ->setModifiedDateTime( 29 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z')) 30 + ->setStartDateTime( 31 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T150000Z')) 32 + ->setEndDateTime( 33 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T160000Z')); 34 + 35 + $ics_data = $this->writeICSSingleEvent($event); 36 + 37 + $this->assertICS('writer-tea-time.ics', $ics_data); 38 + } 39 + 40 + public function testICSWriterChristmas() { 41 + $start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20001225T000000Z'); 42 + $end = PhutilCalendarAbsoluteDateTime::newFromISO8601('20001226T000000Z'); 43 + 44 + $rrule = id(new PhutilCalendarRecurrenceRule()) 45 + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY) 46 + ->setByMonth(array(12)) 47 + ->setByMonthDay(array(25)); 48 + 49 + $event = id(new PhutilCalendarEventNode()) 50 + ->setUID('recurring-christmas') 51 + ->setName('Christmas') 52 + ->setDescription('Festival holiday first occurring in the year 2000.') 53 + ->setStartDateTime($start) 54 + ->setEndDateTime($end) 55 + ->setCreatedDateTime($start) 56 + ->setModifiedDateTime($start) 57 + ->setRecurrenceRule($rrule) 58 + ->setRecurrenceExceptions( 59 + array( 60 + // In 2007, Christmas was cancelled. 61 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20071225T000000Z'), 62 + )) 63 + ->setRecurrenceDates( 64 + array( 65 + // We had an extra early Christmas in 2009. 66 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20091125T000000Z'), 67 + )); 68 + 69 + $ics_data = $this->writeICSSingleEvent($event); 70 + $this->assertICS('writer-recurring-christmas.ics', $ics_data); 71 + } 72 + 73 + public function testICSWriterAllDay() { 74 + $event = id(new PhutilCalendarEventNode()) 75 + ->setUID('christmas-day') 76 + ->setName('Christmas 2016') 77 + ->setDescription('A minor religious holiday.') 78 + ->setCreatedDateTime( 79 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z')) 80 + ->setModifiedDateTime( 81 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z')) 82 + ->setStartDateTime( 83 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161225')) 84 + ->setEndDateTime( 85 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161226')); 86 + 87 + $ics_data = $this->writeICSSingleEvent($event); 88 + 89 + $this->assertICS('writer-christmas.ics', $ics_data); 90 + } 91 + 92 + public function testICSWriterUsers() { 93 + $event = id(new PhutilCalendarEventNode()) 94 + ->setUID('office-party') 95 + ->setName('Office Party') 96 + ->setCreatedDateTime( 97 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z')) 98 + ->setModifiedDateTime( 99 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z')) 100 + ->setStartDateTime( 101 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T200000Z')) 102 + ->setEndDateTime( 103 + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T230000Z')) 104 + ->setOrganizer( 105 + id(new PhutilCalendarUserNode()) 106 + ->setName('Big Boss') 107 + ->setURI('mailto:big.boss@example.com')) 108 + ->addAttendee( 109 + id(new PhutilCalendarUserNode()) 110 + ->setName('Milton') 111 + ->setStatus(PhutilCalendarUserNode::STATUS_INVITED) 112 + ->setURI('mailto:milton@example.com')) 113 + ->addAttendee( 114 + id(new PhutilCalendarUserNode()) 115 + ->setName('Nancy') 116 + ->setStatus(PhutilCalendarUserNode::STATUS_ACCEPTED) 117 + ->setURI('mailto:nancy@example.com')); 118 + 119 + $ics_data = $this->writeICSSingleEvent($event); 120 + $this->assertICS('writer-office-party.ics', $ics_data); 121 + } 122 + 123 + private function writeICSSingleEvent(PhutilCalendarEventNode $event) { 124 + $calendar = id(new PhutilCalendarDocumentNode()) 125 + ->appendChild($event); 126 + 127 + $root = id(new PhutilCalendarRootNode()) 128 + ->appendChild($calendar); 129 + 130 + return $this->writeICS($root); 131 + } 132 + 133 + private function writeICS(PhutilCalendarRootNode $root) { 134 + return id(new PhutilICSWriter()) 135 + ->writeICSDocument($root); 136 + } 137 + 138 + private function assertICS($name, $actual) { 139 + $path = dirname(__FILE__).'/data/'.$name; 140 + $data = Filesystem::readFile($path); 141 + $this->assertEqual($data, $actual, pht('ICS: %s', $name)); 142 + } 143 + 144 + }
+8
src/applications/calendar/parser/ics/__tests__/data/duration.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DTSTART:20160719T095722Z 4 + DURATION:P1DT17H4M23S 5 + SUMMARY:Duration Event 6 + DESCRIPTION:This is an event with a complex duration. 7 + END:VEVENT 8 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-bad-base64.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DATA;VALUE=BINARY;ENCODING=BASE64:<QUACK! QUACK!> 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-bad-boolean.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DUCK;VALUE=BOOLEAN:QUACK 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-bad-datetime.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DTSTART:quack 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-bad-duration.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DURATION:quack 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-empty-datetime.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DTSTART: 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-empty-duration.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DURATION: 4 + END:VEVENT 5 + END:VCALENDAR
+1
src/applications/calendar/parser/ics/__tests__/data/err-extra-end.ics
··· 1 + END:VCALENDAR
+2
src/applications/calendar/parser/ics/__tests__/data/err-initial-unfold.ics
··· 1 + BEGIN:VCALENDAR 2 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-malformed-double-quote.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + A;B="C:D 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-malformed-parameter.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + A;B:C 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-malformed-property.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + PEANUTBUTTER&JELLY:sandwich 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-many-datetime.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DTSTART:20130101,20130101 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-many-duration.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DURATION:P1W,P2W 4 + END:VEVENT 5 + END:VCALENDAR
+2
src/applications/calendar/parser/ics/__tests__/data/err-missing-end.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT
+5
src/applications/calendar/parser/ics/__tests__/data/err-missing-value.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + TRIANGLE;color=red 4 + END:VEVENT 5 + END:VCALENDAR
+4
src/applications/calendar/parser/ics/__tests__/data/err-mixmatched-sections.ics
··· 1 + BEGIN:A 2 + BEGIN:B 3 + END:A 4 + END:B
+5
src/applications/calendar/parser/ics/__tests__/data/err-multiple-parameters.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DTSTART;TZID=A,B:20160915T090000 4 + END:VEVENT 5 + END:VCALENDAR
+1
src/applications/calendar/parser/ics/__tests__/data/err-root-property.ics
··· 1 + NAME:value
+5
src/applications/calendar/parser/ics/__tests__/data/err-unescaped-backslash.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + STORY:The duck coughed up an unescaped backslash: \ 4 + END:VEVENT 5 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/err-unexpected-text.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + SQUARE;color=red" 4 + END:VEVENT 5 + END:VCALENDAR
+8
src/applications/calendar/parser/ics/__tests__/data/floating.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DTSTART:20150101T000000 4 + DURATION:P1D 5 + SUMMARY:New Year's 2015 6 + DESCRIPTION:This is an event with a floating start time. 7 + END:VEVENT 8 + END:VCALENDAR
+5
src/applications/calendar/parser/ics/__tests__/data/good-boolean.ics
··· 1 + BEGIN:VCALENDAR 2 + BEGIN:VEVENT 3 + DUCK;VALUE=BOOLEAN:TRUE 4 + END:VEVENT 5 + END:VCALENDAR
+4
src/applications/calendar/parser/ics/__tests__/data/multiple-vcalendars.ics
··· 1 + BEGIN:VCALENDAR 2 + END:VCALENDAR 3 + BEGIN:VCALENDAR 4 + END:VCALENDAR
+12
src/applications/calendar/parser/ics/__tests__/data/simple.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + CALSCALE:GREGORIAN 4 + BEGIN:VEVENT 5 + CREATED:20160908T172702Z 6 + UID:1CEB57AF-0C9C-402D-B3BD-D75BD4843F68 7 + DTSTART;TZID=America/Los_Angeles:20160915T090000 8 + DTEND;TZID=America/Los_Angeles:20160915T100000 9 + SUMMARY:Simple Event 10 + DESCRIPTION:This is a simple event. 11 + END:VEVENT 12 + END:VCALENDAR
+16
src/applications/calendar/parser/ics/__tests__/data/valarm.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + BEGIN:VEVENT 4 + CREATED:20161027T173727 5 + DTSTAMP:20161027T173727 6 + LAST-MODIFIED:20161027T173727 7 + UID:aic4zm86mg 8 + SUMMARY:alarm event 9 + DTSTART;TZID=Europe/Berlin:20161020T000000 10 + DTEND;TZID=Europe/Berlin:20161020T010000 11 + BEGIN:VALARM 12 + ACTION:AUDIO 13 + TRIGGER:-PT15M 14 + END:VALARM 15 + END:VEVENT 16 + END:VCALENDAR
+14
src/applications/calendar/parser/ics/__tests__/data/weekly.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + BEGIN:VEVENT 4 + TRANSP:OPAQUE 5 + DTEND;VALUE=DATE:20150812 6 + LAST-MODIFIED:20160822T130015Z 7 + UID:4AE69E91-4A51-4B77-8849-85981E037A83 8 + DTSTAMP:20161129T152151Z 9 + SUMMARY:Weekly Event 10 + DTSTART;VALUE=DATE:20150811 11 + CREATED:20141109T163445Z 12 + RRULE:FREQ=WEEKLY 13 + END:VEVENT 14 + END:VCALENDAR
+13
src/applications/calendar/parser/ics/__tests__/data/writer-christmas.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + PRODID:-//Phacility//Phabricator//EN 4 + BEGIN:VEVENT 5 + UID:christmas-day 6 + CREATED:20160901T232425Z 7 + DTSTAMP:20160901T232425Z 8 + DTSTART;VALUE=DATE:20161225 9 + DTEND;VALUE=DATE:20161226 10 + SUMMARY:Christmas 2016 11 + DESCRIPTION:A minor religious holiday. 12 + END:VEVENT 13 + END:VCALENDAR
+15
src/applications/calendar/parser/ics/__tests__/data/writer-office-party.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + PRODID:-//Phacility//Phabricator//EN 4 + BEGIN:VEVENT 5 + UID:office-party 6 + CREATED:20161001T120000Z 7 + DTSTAMP:20161001T120000Z 8 + DTSTART:20161215T200000Z 9 + DTEND:20161215T230000Z 10 + SUMMARY:Office Party 11 + ORGANIZER;CN="Big Boss":mailto:big.boss@example.com 12 + ATTENDEE;CN=Milton;PARTSTAT=NEEDS-ACTION:mailto:milton@example.com 13 + ATTENDEE;CN=Nancy;PARTSTAT=ACCEPTED:mailto:nancy@example.com 14 + END:VEVENT 15 + END:VCALENDAR
+16
src/applications/calendar/parser/ics/__tests__/data/writer-recurring-christmas.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + PRODID:-//Phacility//Phabricator//EN 4 + BEGIN:VEVENT 5 + UID:recurring-christmas 6 + CREATED:20001225T000000Z 7 + DTSTAMP:20001225T000000Z 8 + DTSTART:20001225T000000Z 9 + DTEND:20001226T000000Z 10 + SUMMARY:Christmas 11 + DESCRIPTION:Festival holiday first occurring in the year 2000. 12 + RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25 13 + EXDATE:20071225T000000Z 14 + RDATE:20091125T000000Z 15 + END:VEVENT 16 + END:VCALENDAR
+16
src/applications/calendar/parser/ics/__tests__/data/writer-tea-time.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + PRODID:-//Phacility//Phabricator//EN 4 + BEGIN:VEVENT 5 + UID:tea-time 6 + CREATED:20160915T070000Z 7 + DTSTAMP:20160915T070000Z 8 + DTSTART:20160916T150000Z 9 + DTEND:20160916T160000Z 10 + SUMMARY:Tea Time 11 + DESCRIPTION:Tea and\, perhaps\, crumpets.\nYour presence is requested!\nThi 12 + s is a long list of types of tea to test line wrapping: earl grey tea\, En 13 + glish breakfast tea\, black tea\, green tea\, t-rex\, oolong tea\, mint te 14 + a\, tea with milk. 15 + END:VEVENT 16 + END:VCALENDAR
+12
src/applications/calendar/parser/ics/__tests__/data/zimbra-timezone.ics
··· 1 + BEGIN:VCALENDAR 2 + VERSION:2.0 3 + CALSCALE:GREGORIAN 4 + BEGIN:VEVENT 5 + CREATED:20161104T220244Z 6 + UID:zimbra-timezone 7 + SUMMARY:Zimbra Timezone 8 + DTSTART;TZID="(GMT-05.00) Auto-Detected":20170303T090000 9 + DTSTAMP:20161104T220244Z 10 + SEQUENCE:0 11 + END:VEVENT 12 + END:VCALENDAR
+107
src/infrastructure/lipsum/PhutilLipsumContextFreeGrammar.php
··· 1 + <?php 2 + 3 + final class PhutilLipsumContextFreeGrammar 4 + extends PhutilContextFreeGrammar { 5 + 6 + protected function getRules() { 7 + return array( 8 + 'start' => array( 9 + '[words].', 10 + '[words].', 11 + '[words].', 12 + '[words]: [word], [word], [word] [word].', 13 + '[words]; [lowerwords].', 14 + '[words]!', 15 + '[words], "[words]."', 16 + '[words] ("[upperword] [upperword] [upperword]") [lowerwords].', 17 + '[words]?', 18 + ), 19 + 'words' => array( 20 + '[upperword] [lowerwords]', 21 + ), 22 + 'upperword' => array( 23 + 'Lorem', 24 + 'Ipsum', 25 + 'Dolor', 26 + 'Sit', 27 + 'Amet', 28 + ), 29 + 'lowerwords' => array( 30 + '[word]', 31 + '[word] [word]', 32 + '[word] [word] [word]', 33 + '[word] [word] [word] [word]', 34 + '[word] [word] [word] [word] [word]', 35 + '[word] [word] [word] [word] [word]', 36 + '[word] [word] [word] [word] [word] [word]', 37 + '[word] [word] [word] [word] [word] [word]', 38 + ), 39 + 'word' => array( 40 + 'ad', 41 + 'adipisicing', 42 + 'aliqua', 43 + 'aliquip', 44 + 'amet', 45 + 'anim', 46 + 'aute', 47 + 'cillum', 48 + 'commodo', 49 + 'consectetur', 50 + 'consequat', 51 + 'culpa', 52 + 'cupidatat', 53 + 'deserunt', 54 + 'do', 55 + 'dolor', 56 + 'dolore', 57 + 'duis', 58 + 'ea', 59 + 'eiusmod', 60 + 'elit', 61 + 'enim', 62 + 'esse', 63 + 'est', 64 + 'et', 65 + 'eu', 66 + 'ex', 67 + 'excepteur', 68 + 'exercitation', 69 + 'fugiat', 70 + 'id', 71 + 'in', 72 + 'incididunt', 73 + 'ipsum', 74 + 'irure', 75 + 'labore', 76 + 'laboris', 77 + 'laborum', 78 + 'lorem', 79 + 'magna', 80 + 'minim', 81 + 'mollit', 82 + 'nisi', 83 + 'non', 84 + 'nostrud', 85 + 'nulla', 86 + 'occaecat', 87 + 'officia', 88 + 'pariatur', 89 + 'proident', 90 + 'qui', 91 + 'quis', 92 + 'reprehenderit', 93 + 'sed', 94 + 'sint', 95 + 'sit', 96 + 'sunt', 97 + 'tempor', 98 + 'ullamco', 99 + 'ut', 100 + 'velit', 101 + 'veniam', 102 + 'voluptate', 103 + ), 104 + ); 105 + } 106 + 107 + }
+155
src/infrastructure/lipsum/PhutilRealNameContextFreeGrammar.php
··· 1 + <?php 2 + 3 + final class PhutilRealNameContextFreeGrammar 4 + extends PhutilContextFreeGrammar { 5 + 6 + protected function getRules() { 7 + return array( 8 + 'start' => array( 9 + '[first] [last]', 10 + '[first] [last]', 11 + '[first] [last]', 12 + '[first] [last]', 13 + '[first] [last]', 14 + '[first] [last]', 15 + '[first] [last]', 16 + '[first] [last]', 17 + '[first] [last]-[last]', 18 + '[first] [middle] [last]', 19 + '[first] "[nick]" [last]', 20 + '[first] [particle] [particle] [particle]', 21 + ), 22 + 'first' => array( 23 + 'Mohamed', 24 + 'Youssef', 25 + 'Ahmed', 26 + 'Mahmoud', 27 + 'Mustafa', 28 + 'Fatma', 29 + 'Aya', 30 + 'Noam', 31 + 'Adam', 32 + 'Lucas', 33 + 'Noah', 34 + 'Jakub', 35 + 'Victor', 36 + 'Harry', 37 + 'Rasmus', 38 + 'Nathan', 39 + 'Emil', 40 + 'Charlie', 41 + 'Leon', 42 + 'Dylan', 43 + 'Alexander', 44 + 'Emma', 45 + 'Marie', 46 + 'Lea', 47 + 'Amelia', 48 + 'Hanna', 49 + 'Emily', 50 + 'Sofia', 51 + 'Julia', 52 + 'Santiago', 53 + 'Sebastian', 54 + 'Olivia', 55 + 'Madison', 56 + 'Isabella', 57 + 'Esther', 58 + 'Anya', 59 + 'Camila', 60 + 'Jack', 61 + 'Oliver', 62 + ), 63 + 'nick' => array( 64 + 'Buzz', 65 + 'Juggernaut', 66 + 'Haze', 67 + 'Hawk', 68 + 'Iceman', 69 + 'Killer', 70 + 'Apex', 71 + 'Ocelot', 72 + ), 73 + 'middle' => array( 74 + 'Rose', 75 + 'Grace', 76 + 'Jane', 77 + 'Louise', 78 + 'Jade', 79 + 'James', 80 + 'John', 81 + 'William', 82 + 'Thomas', 83 + 'Alexander', 84 + ), 85 + 'last' => array( 86 + '[termlast]', 87 + '[termlast]', 88 + '[termlast]', 89 + '[termlast]', 90 + '[termlast]', 91 + '[termlast]', 92 + '[termlast]', 93 + '[termlast]', 94 + 'O\'[termlast]', 95 + 'Mc[termlast]', 96 + ), 97 + 'termlast' => array( 98 + 'Smith', 99 + 'Johnson', 100 + 'Williams', 101 + 'Jones', 102 + 'Brown', 103 + 'Davis', 104 + 'Miller', 105 + 'Wilson', 106 + 'Moore', 107 + 'Taylor', 108 + 'Anderson', 109 + 'Thomas', 110 + 'Jackson', 111 + 'White', 112 + 'Harris', 113 + 'Martin', 114 + 'Thompson', 115 + 'Garcia', 116 + 'Marinez', 117 + 'Robinson', 118 + 'Clark', 119 + 'Rodrigues', 120 + 'Lewis', 121 + 'Lee', 122 + 'Walker', 123 + 'Hall', 124 + 'Allen', 125 + 'Young', 126 + 'Hernandex', 127 + 'King', 128 + 'Wang', 129 + 'Li', 130 + 'Zhang', 131 + 'Liu', 132 + 'Chen', 133 + 'Yang', 134 + 'Huang', 135 + 'Zhao', 136 + 'Wu', 137 + 'Zhou', 138 + 'Xu', 139 + 'Sun', 140 + 'Ma', 141 + ), 142 + 'particle' => array( 143 + 'Wu', 144 + 'Xu', 145 + 'Ma', 146 + 'Li', 147 + 'Liu', 148 + 'Shao', 149 + 'Lin', 150 + 'Khan', 151 + ), 152 + ); 153 + } 154 + 155 + }
+254
src/infrastructure/lipsum/code/PhutilCLikeCodeSnippetContextFreeGrammar.php
··· 1 + <?php 2 + 3 + /** 4 + * Generates valid context-free code for most programming languages that could 5 + * pass as C. Except for PHP. But includes Java (mostly). 6 + */ 7 + abstract class PhutilCLikeCodeSnippetContextFreeGrammar 8 + extends PhutilCodeSnippetContextFreeGrammar { 9 + 10 + protected function buildRuleSet() { 11 + return array( 12 + $this->getStmtTerminationGrammarSet(), 13 + $this->getVarNameGrammarSet(), 14 + $this->getNullExprGrammarSet(), 15 + $this->getNumberGrammarSet(), 16 + $this->getExprGrammarSet(), 17 + $this->getCondGrammarSet(), 18 + $this->getLoopGrammarSet(), 19 + $this->getStmtGrammarSet(), 20 + $this->getAssignmentGrammarSet(), 21 + $this->getArithExprGrammarSet(), 22 + $this->getBoolExprGrammarSet(), 23 + $this->getBoolValGrammarSet(), 24 + $this->getTernaryExprGrammarSet(), 25 + 26 + $this->getFuncNameGrammarSet(), 27 + $this->getFuncCallGrammarSet(), 28 + $this->getFuncCallParamGrammarSet(), 29 + $this->getFuncDeclGrammarSet(), 30 + $this->getFuncParamGrammarSet(), 31 + $this->getFuncBodyGrammarSet(), 32 + $this->getFuncReturnGrammarSet(), 33 + ); 34 + } 35 + 36 + protected function getStartGrammarSet() { 37 + $start_grammar = parent::getStartGrammarSet(); 38 + 39 + $start_grammar['start'][] = '[funcdecl]'; 40 + 41 + return $start_grammar; 42 + } 43 + 44 + protected function getStmtTerminationGrammarSet() { 45 + return $this->buildGrammarSet('term', array(';')); 46 + } 47 + 48 + protected function getFuncCallGrammarSet() { 49 + return $this->buildGrammarSet('funccall', 50 + array( 51 + '[funcname]([funccallparam])', 52 + )); 53 + } 54 + 55 + protected function getFuncCallParamGrammarSet() { 56 + return $this->buildGrammarSet('funccallparam', 57 + array( 58 + '', 59 + '[expr]', 60 + '[expr], [expr]', 61 + )); 62 + } 63 + 64 + protected function getFuncDeclGrammarSet() { 65 + return $this->buildGrammarSet('funcdecl', 66 + array( 67 + 'function [funcname]([funcparam]) '. 68 + '{[funcbody, indent, block, trim=right]}', 69 + )); 70 + } 71 + 72 + protected function getFuncParamGrammarSet() { 73 + return $this->buildGrammarSet('funcparam', 74 + array( 75 + '', 76 + '[varname]', 77 + '[varname], [varname]', 78 + '[varname], [varname], [varname]', 79 + )); 80 + } 81 + 82 + protected function getFuncBodyGrammarSet() { 83 + return $this->buildGrammarSet('funcbody', 84 + array( 85 + "[stmt]\n[stmt]\n[funcreturn]", 86 + "[stmt]\n[stmt]\n[stmt]\n[funcreturn]", 87 + "[stmt]\n[stmt]\n[stmt]\n[stmt]\n[funcreturn]", 88 + )); 89 + } 90 + 91 + protected function getFuncReturnGrammarSet() { 92 + return $this->buildGrammarSet('funcreturn', 93 + array( 94 + 'return [expr][term]', 95 + '', 96 + )); 97 + } 98 + 99 + // Not really C, but put it here because of the curly braces and mostly shared 100 + // among Java and PHP 101 + protected function getClassDeclGrammarSet() { 102 + return $this->buildGrammarSet('classdecl', 103 + array( 104 + '[classinheritancemod] class [classname] {[classbody, indent, block]}', 105 + 'class [classname] {[classbody, indent, block]}', 106 + )); 107 + } 108 + 109 + protected function getClassNameGrammarSet() { 110 + return $this->buildGrammarSet('classname', 111 + array( 112 + 'MuffinHouse', 113 + 'MuffinReader', 114 + 'MuffinAwesomizer', 115 + 'SuperException', 116 + 'Librarian', 117 + 'Book', 118 + 'Ball', 119 + 'BallOfCode', 120 + 'AliceAndBobsSharedSecret', 121 + 'FileInputStream', 122 + 'FileOutputStream', 123 + 'BufferedReader', 124 + 'BufferedWriter', 125 + 'Cardigan', 126 + 'HouseOfCards', 127 + 'UmbrellaClass', 128 + 'GenericThing', 129 + )); 130 + } 131 + 132 + protected function getClassBodyGrammarSet() { 133 + return $this->buildGrammarSet('classbody', 134 + array( 135 + '[methoddecl]', 136 + "[methoddecl]\n\n[methoddecl]", 137 + "[propdecl]\n[propdecl]\n\n[methoddecl]\n\n[methoddecl]", 138 + "[propdecl]\n[propdecl]\n[propdecl]\n\n[methoddecl]\n\n[methoddecl]". 139 + "\n\n[methoddecl]", 140 + )); 141 + } 142 + 143 + protected function getVisibilityGrammarSet() { 144 + return $this->buildGrammarSet('visibility', 145 + array( 146 + 'private', 147 + 'protected', 148 + 'public', 149 + )); 150 + } 151 + 152 + protected function getClassInheritanceModGrammarSet() { 153 + return $this->buildGrammarSet('classinheritancemod', 154 + array( 155 + 'final', 156 + 'abstract', 157 + )); 158 + } 159 + 160 + // Keeping this separate so we won't give abstract methods a function body 161 + protected function getMethodInheritanceModGrammarSet() { 162 + return $this->buildGrammarSet('methodinheritancemod', 163 + array( 164 + 'final', 165 + )); 166 + } 167 + 168 + protected function getMethodDeclGrammarSet() { 169 + return $this->buildGrammarSet('methoddecl', 170 + array( 171 + '[visibility] [methodfuncdecl]', 172 + '[visibility] [methodfuncdecl]', 173 + '[methodinheritancemod] [visibility] [methodfuncdecl]', 174 + '[abstractmethoddecl]', 175 + )); 176 + } 177 + 178 + protected function getMethodFuncDeclGrammarSet() { 179 + return $this->buildGrammarSet('methodfuncdecl', 180 + array( 181 + 'function [funcname]([funcparam]) '. 182 + '{[methodbody, indent, block, trim=right]}', 183 + )); 184 + } 185 + 186 + protected function getMethodBodyGrammarSet() { 187 + return $this->buildGrammarSet('methodbody', 188 + array( 189 + "[methodstmt]\n[methodbody]", 190 + "[methodstmt]\n[funcreturn]", 191 + )); 192 + } 193 + 194 + protected function getMethodStmtGrammarSet() { 195 + $stmts = $this->getStmtGrammarSet(); 196 + 197 + return $this->buildGrammarSet('methodstmt', 198 + array_merge( 199 + $stmts['stmt'], 200 + array( 201 + '[methodcall][term]', 202 + ))); 203 + } 204 + 205 + protected function getMethodCallGrammarSet() { 206 + // Java/JavaScript 207 + return $this->buildGrammarSet('methodcall', 208 + array( 209 + 'this.[funccall]', 210 + '[varname].[funccall]', 211 + '[classname].[funccall]', 212 + )); 213 + } 214 + 215 + protected function getAbstractMethodDeclGrammarSet() { 216 + return $this->buildGrammarSet('abstractmethoddecl', 217 + array( 218 + 'abstract function [funcname]([funcparam])[term]', 219 + )); 220 + } 221 + 222 + protected function getPropDeclGrammarSet() { 223 + return $this->buildGrammarSet('propdecl', 224 + array( 225 + '[visibility] [varname][term]', 226 + )); 227 + } 228 + 229 + protected function getClassRuleSets() { 230 + return array( 231 + $this->getClassInheritanceModGrammarSet(), 232 + $this->getMethodInheritanceModGrammarSet(), 233 + $this->getClassDeclGrammarSet(), 234 + $this->getClassNameGrammarSet(), 235 + $this->getClassBodyGrammarSet(), 236 + $this->getMethodDeclGrammarSet(), 237 + $this->getMethodFuncDeclGrammarSet(), 238 + $this->getMethodBodyGrammarSet(), 239 + $this->getMethodStmtGrammarSet(), 240 + $this->getMethodCallGrammarSet(), 241 + $this->getAbstractMethodDeclGrammarSet(), 242 + $this->getPropDeclGrammarSet(), 243 + $this->getVisibilityGrammarSet(), 244 + ); 245 + } 246 + 247 + public function generateClass() { 248 + $rules = array_merge($this->getRules(), $this->getClassRuleSets()); 249 + $rules['start'] = array('[classdecl]'); 250 + $count = 0; 251 + return $this->applyRules('[start]', $count, $rules); 252 + } 253 + 254 + }
+205
src/infrastructure/lipsum/code/PhutilCodeSnippetContextFreeGrammar.php
··· 1 + <?php 2 + 3 + /** 4 + * Generates non-sense code snippets according to context-free rules, respecting 5 + * indentation etc. 6 + * 7 + * Also provides a common ruleset shared among many mainstream programming 8 + * languages (that is, not Lisp). 9 + */ 10 + abstract class PhutilCodeSnippetContextFreeGrammar 11 + extends PhutilContextFreeGrammar { 12 + 13 + public function generate() { 14 + // A trailing newline is favorable for source code 15 + return trim(parent::generate())."\n"; 16 + } 17 + 18 + final protected function getRules() { 19 + return array_merge( 20 + $this->getStartGrammarSet(), 21 + $this->getStmtGrammarSet(), 22 + array_mergev($this->buildRuleSet())); 23 + } 24 + 25 + abstract protected function buildRuleSet(); 26 + 27 + protected function buildGrammarSet($name, array $set) { 28 + return array( 29 + $name => $set, 30 + ); 31 + } 32 + 33 + protected function getStartGrammarSet() { 34 + return $this->buildGrammarSet('start', 35 + array( 36 + "[stmt]\n[stmt]", 37 + "[stmt]\n[stmt]\n[stmt]", 38 + "[stmt]\n[stmt]\n[stmt]\n[stmt]", 39 + )); 40 + } 41 + 42 + protected function getStmtGrammarSet() { 43 + return $this->buildGrammarSet('stmt', 44 + array( 45 + '[assignment][term]', 46 + '[assignment][term]', 47 + '[assignment][term]', 48 + '[assignment][term]', 49 + '[funccall][term]', 50 + '[funccall][term]', 51 + '[funccall][term]', 52 + '[funccall][term]', 53 + '[cond]', 54 + '[loop]', 55 + )); 56 + } 57 + 58 + protected function getFuncNameGrammarSet() { 59 + return $this->buildGrammarSet('funcname', 60 + array( 61 + 'do_something', 62 + 'nonempty', 63 + 'noOp', 64 + 'call_user_func', 65 + 'getenv', 66 + 'render', 67 + 'super', 68 + 'derpify', 69 + 'awesomize', 70 + 'equals', 71 + 'run', 72 + 'flee', 73 + 'fight', 74 + 'notify', 75 + 'listen', 76 + 'calculate', 77 + 'aim', 78 + 'open', 79 + )); 80 + } 81 + 82 + protected function getVarNameGrammarSet() { 83 + return $this->buildGrammarSet('varname', 84 + array( 85 + 'is_something', 86 + 'object', 87 + 'name', 88 + 'token', 89 + 'label', 90 + 'piece_of_the_pie', 91 + 'type', 92 + 'state', 93 + 'param', 94 + 'action', 95 + 'key', 96 + 'timeout', 97 + 'result', 98 + )); 99 + } 100 + 101 + protected function getNullExprGrammarSet() { 102 + return $this->buildGrammarSet('null', array('null')); 103 + } 104 + 105 + protected function getNumberGrammarSet() { 106 + return $this->buildGrammarSet('number', 107 + array( 108 + mt_rand(-1, 100), 109 + mt_rand(-100, 1000), 110 + mt_rand(-1000, 5000), 111 + mt_rand(0, 1).'.'.mt_rand(1, 1000), 112 + mt_rand(0, 50).'.'.mt_rand(1, 1000), 113 + )); 114 + } 115 + 116 + protected function getExprGrammarSet() { 117 + return $this->buildGrammarSet('expr', 118 + array( 119 + '[null]', 120 + '[number]', 121 + '[number]', 122 + '[varname]', 123 + '[varname]', 124 + '[boolval]', 125 + '[boolval]', 126 + '[boolexpr]', 127 + '[boolexpr]', 128 + '[funccall]', 129 + '[arithexpr]', 130 + '[arithexpr]', 131 + // Some random strings 132 + '"'.Filesystem::readRandomCharacters(4).'"', 133 + '"'.Filesystem::readRandomCharacters(5).'"', 134 + )); 135 + } 136 + 137 + protected function getBoolExprGrammarSet() { 138 + return $this->buildGrammarSet('boolexpr', 139 + array( 140 + '[varname]', 141 + '![varname]', 142 + '[varname] == [boolval]', 143 + '[varname] != [boolval]', 144 + '[ternary]', 145 + )); 146 + } 147 + 148 + protected function getBoolValGrammarSet() { 149 + return $this->buildGrammarSet('boolval', 150 + array( 151 + 'true', 152 + 'false', 153 + )); 154 + } 155 + 156 + protected function getArithExprGrammarSet() { 157 + return $this->buildGrammarSet('arithexpr', 158 + array( 159 + '[varname]++', 160 + '++[varname]', 161 + '[varname] + [number]', 162 + '[varname]--', 163 + '--[varname]', 164 + '[varname] - [number]', 165 + )); 166 + } 167 + 168 + protected function getAssignmentGrammarSet() { 169 + return $this->buildGrammarSet('assignment', 170 + array( 171 + '[varname] = [expr]', 172 + '[varname] = [arithexpr]', 173 + '[varname] += [expr]', 174 + )); 175 + } 176 + 177 + protected function getCondGrammarSet() { 178 + return $this->buildGrammarSet('cond', 179 + array( 180 + 'if ([boolexpr]) {[stmt, indent, block]}', 181 + 'if ([boolexpr]) {[stmt, indent, block]} else {[stmt, indent, block]}', 182 + )); 183 + } 184 + 185 + protected function getLoopGrammarSet() { 186 + return $this->buildGrammarSet('loop', 187 + array( 188 + 'while ([boolexpr]) {[stmt, indent, block]}', 189 + 'do {[stmt, indent, block]} while ([boolexpr])[term]', 190 + 'for ([assignment]; [boolexpr]; [expr]) {[stmt, indent, block]}', 191 + )); 192 + } 193 + 194 + protected function getTernaryExprGrammarSet() { 195 + return $this->buildGrammarSet('ternary', 196 + array( 197 + '[boolexpr] ? [expr] : [expr]', 198 + )); 199 + } 200 + 201 + protected function getStmtTerminationGrammarSet() { 202 + return $this->buildGrammarSet('term', array('')); 203 + } 204 + 205 + }
+184
src/infrastructure/lipsum/code/PhutilJavaCodeSnippetContextFreeGrammar.php
··· 1 + <?php 2 + 3 + final class PhutilJavaCodeSnippetContextFreeGrammar 4 + extends PhutilCLikeCodeSnippetContextFreeGrammar { 5 + 6 + protected function buildRuleSet() { 7 + $parent_ruleset = parent::buildRuleSet(); 8 + $rulesset = array_merge($parent_ruleset, $this->getClassRuleSets()); 9 + 10 + $rulesset[] = $this->getTypeNameGrammarSet(); 11 + $rulesset[] = $this->getNamespaceDeclGrammarSet(); 12 + $rulesset[] = $this->getNamespaceNameGrammarSet(); 13 + $rulesset[] = $this->getImportGrammarSet(); 14 + $rulesset[] = $this->getMethodReturnTypeGrammarSet(); 15 + $rulesset[] = $this->getMethodNameGrammarSet(); 16 + $rulesset[] = $this->getVarDeclGrammarSet(); 17 + $rulesset[] = $this->getClassDerivGrammarSet(); 18 + 19 + return $rulesset; 20 + } 21 + 22 + protected function getStartGrammarSet() { 23 + return $this->buildGrammarSet('start', 24 + array( 25 + '[import, block][nmspdecl, block][classdecl, block]', 26 + )); 27 + } 28 + 29 + protected function getClassDeclGrammarSet() { 30 + return $this->buildGrammarSet('classdecl', 31 + array( 32 + '[classinheritancemod] [visibility] class [classname][classderiv] '. 33 + '{[classbody, indent, block]}', 34 + '[visibility] class [classname][classderiv] '. 35 + '{[classbody, indent, block]}', 36 + )); 37 + } 38 + 39 + protected function getClassDerivGrammarSet() { 40 + return $this->buildGrammarSet('classderiv', 41 + array( 42 + ' extends [classname]', 43 + '', 44 + '', 45 + )); 46 + } 47 + 48 + protected function getTypeNameGrammarSet() { 49 + return $this->buildGrammarSet('type', 50 + array( 51 + 'int', 52 + 'boolean', 53 + 'char', 54 + 'short', 55 + 'long', 56 + 'float', 57 + 'double', 58 + '[classname]', 59 + '[type][]', 60 + )); 61 + } 62 + 63 + protected function getMethodReturnTypeGrammarSet() { 64 + return $this->buildGrammarSet('methodreturn', 65 + array( 66 + '[type]', 67 + 'void', 68 + )); 69 + } 70 + 71 + protected function getNamespaceDeclGrammarSet() { 72 + return $this->buildGrammarSet('nmspdecl', 73 + array( 74 + 'package [nmspname][term]', 75 + )); 76 + } 77 + 78 + protected function getNamespaceNameGrammarSet() { 79 + return $this->buildGrammarSet('nmspname', 80 + array( 81 + 'java.lang', 82 + 'java.io', 83 + 'com.example.proj.std', 84 + 'derp.example.www', 85 + )); 86 + } 87 + 88 + protected function getImportGrammarSet() { 89 + return $this->buildGrammarSet('import', 90 + array( 91 + 'import [nmspname][term]', 92 + 'import [nmspname].*[term]', 93 + 'import [nmspname].[classname][term]', 94 + )); 95 + } 96 + 97 + protected function getExprGrammarSet() { 98 + $expr = parent::getExprGrammarSet(); 99 + 100 + $expr['expr'][] = 'new [classname]([funccallparam])'; 101 + 102 + $expr['expr'][] = '[methodcall]'; 103 + $expr['expr'][] = '[methodcall]'; 104 + $expr['expr'][] = '[methodcall]'; 105 + $expr['expr'][] = '[methodcall]'; 106 + 107 + // Add some 'char's 108 + for ($ii = 0; $ii < 2; $ii++) { 109 + $expr['expr'][] = "'".Filesystem::readRandomCharacters(1)."'"; 110 + } 111 + 112 + return $expr; 113 + } 114 + 115 + protected function getStmtGrammarSet() { 116 + $stmt = parent::getStmtGrammarSet(); 117 + 118 + $stmt['stmt'][] = '[vardecl]'; 119 + $stmt['stmt'][] = '[vardecl]'; 120 + // `try` to `throw` a `Ball`! 121 + $stmt['stmt'][] = 'throw [classname][term]'; 122 + 123 + return $stmt; 124 + } 125 + 126 + protected function getPropDeclGrammarSet() { 127 + return $this->buildGrammarSet('propdecl', 128 + array( 129 + '[visibility] [type] [varname][term]', 130 + )); 131 + } 132 + 133 + protected function getVarDeclGrammarSet() { 134 + return $this->buildGrammarSet('vardecl', 135 + array( 136 + '[type] [varname][term]', 137 + '[type] [assignment][term]', 138 + )); 139 + } 140 + 141 + protected function getFuncNameGrammarSet() { 142 + return $this->buildGrammarSet('funcname', 143 + array( 144 + '[methodname]', 145 + '[classname].[methodname]', 146 + // This is just silly (too much recursion) 147 + // '[classname].[funcname]', 148 + // Don't do this for now, it just clutters up output (thanks to rec.) 149 + // '[nmspname].[classname].[methodname]', 150 + )); 151 + } 152 + 153 + // Renamed from `funcname` 154 + protected function getMethodNameGrammarSet() { 155 + $funcnames = head(parent::getFuncNameGrammarSet()); 156 + return $this->buildGrammarSet('methodname', $funcnames); 157 + } 158 + 159 + protected function getMethodFuncDeclGrammarSet() { 160 + return $this->buildGrammarSet('methodfuncdecl', 161 + array( 162 + '[methodreturn] [methodname]([funcparam]) '. 163 + '{[methodbody, indent, block, trim=right]}', 164 + )); 165 + } 166 + 167 + protected function getFuncParamGrammarSet() { 168 + return $this->buildGrammarSet('funcparam', 169 + array( 170 + '', 171 + '[type] [varname]', 172 + '[type] [varname], [type] [varname]', 173 + '[type] [varname], [type] [varname], [type] [varname]', 174 + )); 175 + } 176 + 177 + protected function getAbstractMethodDeclGrammarSet() { 178 + return $this->buildGrammarSet('abstractmethoddecl', 179 + array( 180 + 'abstract [methodreturn] [methodname]([funcparam])[term]', 181 + )); 182 + } 183 + 184 + }
+57
src/infrastructure/lipsum/code/PhutilPHPCodeSnippetContextFreeGrammar.php
··· 1 + <?php 2 + 3 + final class PhutilPHPCodeSnippetContextFreeGrammar 4 + extends PhutilCLikeCodeSnippetContextFreeGrammar { 5 + 6 + protected function buildRuleSet() { 7 + return array_merge(parent::buildRuleSet(), $this->getClassRuleSets()); 8 + } 9 + 10 + protected function getStartGrammarSet() { 11 + $start_grammar = parent::getStartGrammarSet(); 12 + 13 + $start_grammar['start'][] = '[classdecl]'; 14 + $start_grammar['start'][] = '[classdecl]'; 15 + 16 + return $start_grammar; 17 + } 18 + 19 + protected function getExprGrammarSet() { 20 + $expr = parent::getExprGrammarSet(); 21 + 22 + $expr['expr'][] = 'new [classname]([funccallparam])'; 23 + 24 + $expr['expr'][] = '[classname]::[funccall]'; 25 + 26 + return $expr; 27 + } 28 + 29 + protected function getVarNameGrammarSet() { 30 + $varnames = parent::getVarNameGrammarSet(); 31 + 32 + foreach ($varnames as $vn_key => $vn_val) { 33 + foreach ($vn_val as $vv_key => $vv_value) { 34 + $varnames[$vn_key][$vv_key] = '$'.$vv_value; 35 + } 36 + } 37 + 38 + return $varnames; 39 + } 40 + 41 + protected function getFuncNameGrammarSet() { 42 + return $this->buildGrammarSet('funcname', 43 + array_mergev(get_defined_functions())); 44 + } 45 + 46 + protected function getMethodCallGrammarSet() { 47 + return $this->buildGrammarSet('methodcall', 48 + array( 49 + '$this->[funccall]', 50 + 'self::[funccall]', 51 + 'static::[funccall]', 52 + '[varname]->[funccall]', 53 + '[classname]::[funccall]', 54 + )); 55 + } 56 + 57 + }
+176
src/infrastructure/markup/PhutilRemarkupBlockStorage.php
··· 1 + <?php 2 + 3 + /** 4 + * Remarkup prevents several classes of text-processing problems by replacing 5 + * tokens in the text as they are marked up. For example, if you write something 6 + * like this: 7 + * 8 + * //D12// 9 + * 10 + * It is processed in several stages. First the "D12" matches and is replaced 11 + * with a token, in the form of "<0x01><ID number><literal "Z">". The first 12 + * byte, "<0x01>" is a single byte with value 1 that marks a token. If this is 13 + * token ID "444", the text may now look like this: 14 + * 15 + * //<0x01>444Z// 16 + * 17 + * Now the italics match and are replaced, using the next token ID: 18 + * 19 + * <0x01>445Z 20 + * 21 + * When processing completes, all the tokens are replaced with their final 22 + * equivalents. For example, token 444 is evaluated to: 23 + * 24 + * <a href="http://...">...</a> 25 + * 26 + * Then token 445 is evaluated: 27 + * 28 + * <em><0x01>444Z</em> 29 + * 30 + * ...and all tokens it contains are replaced: 31 + * 32 + * <em><a href="http://...">...</a></em> 33 + * 34 + * If we didn't do this, the italics rule could match the "//" in "http://", 35 + * or any other number of processing mistakes could occur, some of which create 36 + * security risks. 37 + * 38 + * This class generates keys, and stores the map of keys to replacement text. 39 + */ 40 + final class PhutilRemarkupBlockStorage extends Phobject { 41 + 42 + const MAGIC_BYTE = "\1"; 43 + 44 + private $map = array(); 45 + private $index = 0; 46 + 47 + public function store($text) { 48 + $key = self::MAGIC_BYTE.(++$this->index).'Z'; 49 + $this->map[$key] = $text; 50 + return $key; 51 + } 52 + 53 + public function restore($corpus, $text_mode = false) { 54 + $map = $this->map; 55 + 56 + if (!$text_mode) { 57 + foreach ($map as $key => $content) { 58 + $map[$key] = phutil_escape_html($content); 59 + } 60 + $corpus = phutil_escape_html($corpus); 61 + } 62 + 63 + // NOTE: Tokens may contain other tokens: for example, a table may have 64 + // links inside it. So we can't do a single simple find/replace, because 65 + // we need to find and replace child tokens inside the content of parent 66 + // tokens. 67 + 68 + // However, we know that rules which have child tokens must always store 69 + // all their child tokens first, before they store their parent token: you 70 + // have to pass the "store(text)" API a block of text with tokens already 71 + // in it, so you must have created child tokens already. 72 + 73 + // Thus, all child tokens will appear in the list before parent tokens, so 74 + // if we start at the beginning of the list and replace all the tokens we 75 + // find in each piece of content, we'll end up expanding all subtokens 76 + // correctly. 77 + 78 + $map[] = $corpus; 79 + $seen = array(); 80 + foreach ($map as $key => $content) { 81 + $seen[$key] = true; 82 + 83 + // If the content contains no token magic, we don't need to replace 84 + // anything. 85 + if (strpos($content, self::MAGIC_BYTE) === false) { 86 + continue; 87 + } 88 + 89 + $matches = null; 90 + preg_match_all( 91 + '/'.self::MAGIC_BYTE.'\d+Z/', 92 + $content, 93 + $matches, 94 + PREG_OFFSET_CAPTURE); 95 + 96 + $matches = $matches[0]; 97 + 98 + // See PHI1114. We're replacing all the matches in one pass because this 99 + // is significantly faster than doing "substr_replace()" in a loop if the 100 + // corpus is large and we have a large number of matches. 101 + 102 + // Build a list of string pieces in "$parts" by interleaving the 103 + // plain strings between each token and the replacement token text, then 104 + // implode the whole thing when we're done. 105 + 106 + $parts = array(); 107 + $pos = 0; 108 + foreach ($matches as $next) { 109 + $subkey = $next[0]; 110 + 111 + // If we've matched a token pattern but don't actually have any 112 + // corresponding token, just skip this match. This should not be 113 + // possible, and should perhaps be an error. 114 + if (!isset($seen[$subkey])) { 115 + if (!isset($map[$subkey])) { 116 + throw new Exception( 117 + pht( 118 + 'Matched token key "%s" while processing remarkup block, but '. 119 + 'this token does not exist in the token map.', 120 + $subkey)); 121 + } else { 122 + throw new Exception( 123 + pht( 124 + 'Matched token key "%s" while processing remarkup block, but '. 125 + 'this token appears later in the list than the key being '. 126 + 'processed ("%s").', 127 + $subkey, 128 + $key)); 129 + } 130 + } 131 + 132 + $subpos = $next[1]; 133 + 134 + // If there were any non-token bytes since the last token, add them. 135 + if ($subpos > $pos) { 136 + $parts[] = substr($content, $pos, $subpos - $pos); 137 + } 138 + 139 + // Add the token replacement text. 140 + $parts[] = $map[$subkey]; 141 + 142 + // Move the non-token cursor forward over the token. 143 + $pos = $subpos + strlen($subkey); 144 + } 145 + 146 + // Add any leftover non-token bytes after the last token. 147 + $parts[] = substr($content, $pos); 148 + 149 + $content = implode('', $parts); 150 + 151 + $map[$key] = $content; 152 + } 153 + $corpus = last($map); 154 + 155 + if (!$text_mode) { 156 + $corpus = phutil_safe_html($corpus); 157 + } 158 + 159 + return $corpus; 160 + } 161 + 162 + public function overwrite($key, $new_text) { 163 + $this->map[$key] = $new_text; 164 + return $this; 165 + } 166 + 167 + public function getMap() { 168 + return $this->map; 169 + } 170 + 171 + public function setMap(array $map) { 172 + $this->map = $map; 173 + return $this; 174 + } 175 + 176 + }
+36
src/infrastructure/markup/blockrule/PhutilRemarkupBlockInterpreter.php
··· 1 + <?php 2 + 3 + abstract class PhutilRemarkupBlockInterpreter extends Phobject { 4 + 5 + private $engine; 6 + 7 + final public function setEngine($engine) { 8 + $this->engine = $engine; 9 + return $this; 10 + } 11 + 12 + final public function getEngine() { 13 + return $this->engine; 14 + } 15 + 16 + /** 17 + * @return string 18 + */ 19 + abstract public function getInterpreterName(); 20 + 21 + abstract public function markupContent($content, array $argv); 22 + 23 + protected function markupError($string) { 24 + if ($this->getEngine()->isTextMode()) { 25 + return '('.$string.')'; 26 + } else { 27 + return phutil_tag( 28 + 'div', 29 + array( 30 + 'class' => 'remarkup-interpreter-error', 31 + ), 32 + $string); 33 + } 34 + } 35 + 36 + }
+170
src/infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php
··· 1 + <?php 2 + 3 + abstract class PhutilRemarkupBlockRule extends Phobject { 4 + 5 + private $engine; 6 + private $rules = array(); 7 + 8 + /** 9 + * Determine the order in which blocks execute. Blocks with smaller priority 10 + * numbers execute sooner than blocks with larger priority numbers. The 11 + * default priority for blocks is `500`. 12 + * 13 + * Priorities are used to disambiguate syntax which can match multiple 14 + * patterns. For example, ` - Lorem ipsum...` may be a code block or a 15 + * list. 16 + * 17 + * @return int Priority at which this block should execute. 18 + */ 19 + public function getPriority() { 20 + return 500; 21 + } 22 + 23 + final public function getPriorityVector() { 24 + return id(new PhutilSortVector()) 25 + ->addInt($this->getPriority()) 26 + ->addString(get_class($this)); 27 + } 28 + 29 + abstract public function markupText($text, $children); 30 + 31 + /** 32 + * This will get an array of unparsed lines and return the number of lines 33 + * from the first array value that it can parse. 34 + * 35 + * @param array $lines 36 + * @param int $cursor 37 + * 38 + * @return int 39 + */ 40 + abstract public function getMatchingLineCount(array $lines, $cursor); 41 + 42 + protected function didMarkupText() { 43 + return; 44 + } 45 + 46 + final public function setEngine(PhutilRemarkupEngine $engine) { 47 + $this->engine = $engine; 48 + $this->updateRules(); 49 + return $this; 50 + } 51 + 52 + final protected function getEngine() { 53 + return $this->engine; 54 + } 55 + 56 + public function setMarkupRules(array $rules) { 57 + assert_instances_of($rules, 'PhutilRemarkupRule'); 58 + $this->rules = $rules; 59 + $this->updateRules(); 60 + return $this; 61 + } 62 + 63 + private function updateRules() { 64 + $engine = $this->getEngine(); 65 + if ($engine) { 66 + $this->rules = msort($this->rules, 'getPriority'); 67 + foreach ($this->rules as $rule) { 68 + $rule->setEngine($engine); 69 + } 70 + } 71 + return $this; 72 + } 73 + 74 + final public function getMarkupRules() { 75 + return $this->rules; 76 + } 77 + 78 + final public function postprocess() { 79 + $this->didMarkupText(); 80 + } 81 + 82 + final protected function applyRules($text) { 83 + foreach ($this->getMarkupRules() as $rule) { 84 + $text = $rule->apply($text); 85 + } 86 + return $text; 87 + } 88 + 89 + public function supportsChildBlocks() { 90 + return false; 91 + } 92 + 93 + public function extractChildText($text) { 94 + throw new PhutilMethodNotImplementedException(); 95 + } 96 + 97 + protected function renderRemarkupTable(array $out_rows) { 98 + assert_instances_of($out_rows, 'array'); 99 + 100 + if ($this->getEngine()->isTextMode()) { 101 + $lengths = array(); 102 + foreach ($out_rows as $r => $row) { 103 + foreach ($row['content'] as $c => $cell) { 104 + $text = $this->getEngine()->restoreText($cell['content']); 105 + $lengths[$c][$r] = phutil_utf8_strlen($text); 106 + } 107 + } 108 + $max_lengths = array_map('max', $lengths); 109 + 110 + $out = array(); 111 + foreach ($out_rows as $r => $row) { 112 + $headings = false; 113 + foreach ($row['content'] as $c => $cell) { 114 + $length = $max_lengths[$c] - $lengths[$c][$r]; 115 + $out[] = '| '.$cell['content'].str_repeat(' ', $length).' '; 116 + if ($cell['type'] == 'th') { 117 + $headings = true; 118 + } 119 + } 120 + $out[] = "|\n"; 121 + 122 + if ($headings) { 123 + foreach ($row['content'] as $c => $cell) { 124 + $char = ($cell['type'] == 'th' ? '-' : ' '); 125 + $out[] = '| '.str_repeat($char, $max_lengths[$c]).' '; 126 + } 127 + $out[] = "|\n"; 128 + } 129 + } 130 + 131 + return rtrim(implode('', $out), "\n"); 132 + } 133 + 134 + if ($this->getEngine()->isHTMLMailMode()) { 135 + $table_attributes = array( 136 + 'style' => 'border-collapse: separate; 137 + border-spacing: 1px; 138 + background: #d3d3d3; 139 + margin: 12px 0;', 140 + ); 141 + $cell_attributes = array( 142 + 'style' => 'background: #ffffff; 143 + padding: 3px 6px;', 144 + ); 145 + } else { 146 + $table_attributes = array( 147 + 'class' => 'remarkup-table', 148 + ); 149 + $cell_attributes = array(); 150 + } 151 + 152 + $out = array(); 153 + $out[] = "\n"; 154 + foreach ($out_rows as $row) { 155 + $cells = array(); 156 + foreach ($row['content'] as $cell) { 157 + $cells[] = phutil_tag( 158 + $cell['type'], 159 + $cell_attributes, 160 + $cell['content']); 161 + } 162 + $out[] = phutil_tag($row['type'], array(), $cells); 163 + $out[] = "\n"; 164 + } 165 + 166 + $table = phutil_tag('table', $table_attributes, $out); 167 + return phutil_tag_div('remarkup-table-wrap', $table); 168 + } 169 + 170 + }
+252
src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupCodeBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getMatchingLineCount(array $lines, $cursor) { 6 + $num_lines = 0; 7 + $match_ticks = null; 8 + if (preg_match('/^(\s{2,}).+/', $lines[$cursor])) { 9 + $match_ticks = false; 10 + } else if (preg_match('/^\s*(```)/', $lines[$cursor])) { 11 + $match_ticks = true; 12 + } else { 13 + return $num_lines; 14 + } 15 + 16 + $num_lines++; 17 + 18 + if ($match_ticks && 19 + preg_match('/^\s*(```)(.*)(```)\s*$/', $lines[$cursor])) { 20 + return $num_lines; 21 + } 22 + 23 + $cursor++; 24 + 25 + while (isset($lines[$cursor])) { 26 + if ($match_ticks) { 27 + if (preg_match('/```\s*$/', $lines[$cursor])) { 28 + $num_lines++; 29 + break; 30 + } 31 + $num_lines++; 32 + } else { 33 + if (strlen(trim($lines[$cursor]))) { 34 + if (!preg_match('/^\s{2,}/', $lines[$cursor])) { 35 + break; 36 + } 37 + } 38 + $num_lines++; 39 + } 40 + $cursor++; 41 + } 42 + 43 + return $num_lines; 44 + } 45 + 46 + public function markupText($text, $children) { 47 + if (preg_match('/^\s*```/', $text)) { 48 + // If this is a ```-style block, trim off the backticks and any leading 49 + // blank line. 50 + $text = preg_replace('/^\s*```(\s*\n)?/', '', $text); 51 + $text = preg_replace('/```\s*$/', '', $text); 52 + } 53 + 54 + $lines = explode("\n", $text); 55 + while ($lines && !strlen(last($lines))) { 56 + unset($lines[last_key($lines)]); 57 + } 58 + 59 + $options = array( 60 + 'counterexample' => false, 61 + 'lang' => null, 62 + 'name' => null, 63 + 'lines' => null, 64 + ); 65 + 66 + $parser = new PhutilSimpleOptions(); 67 + $custom = $parser->parse(head($lines)); 68 + if ($custom) { 69 + $valid = true; 70 + foreach ($custom as $key => $value) { 71 + if (!array_key_exists($key, $options)) { 72 + $valid = false; 73 + break; 74 + } 75 + } 76 + if ($valid) { 77 + array_shift($lines); 78 + $options = $custom + $options; 79 + } 80 + } 81 + 82 + // Normalize the text back to a 0-level indent. 83 + $min_indent = 80; 84 + foreach ($lines as $line) { 85 + for ($ii = 0; $ii < strlen($line); $ii++) { 86 + if ($line[$ii] != ' ') { 87 + $min_indent = min($ii, $min_indent); 88 + break; 89 + } 90 + } 91 + } 92 + 93 + $text = implode("\n", $lines); 94 + if ($min_indent) { 95 + $indent_string = str_repeat(' ', $min_indent); 96 + $text = preg_replace('/^'.$indent_string.'/m', '', $text); 97 + } 98 + 99 + if ($this->getEngine()->isTextMode()) { 100 + $out = array(); 101 + 102 + $header = array(); 103 + if ($options['counterexample']) { 104 + $header[] = 'counterexample'; 105 + } 106 + if ($options['name'] != '') { 107 + $header[] = 'name='.$options['name']; 108 + } 109 + if ($header) { 110 + $out[] = implode(', ', $header); 111 + } 112 + 113 + $text = preg_replace('/^/m', ' ', $text); 114 + $out[] = $text; 115 + 116 + return implode("\n", $out); 117 + } 118 + 119 + if (empty($options['lang'])) { 120 + // If the user hasn't specified "lang=..." explicitly, try to guess the 121 + // language. If we fail, fall back to configured defaults. 122 + $lang = PhutilLanguageGuesser::guessLanguage($text); 123 + if (!$lang) { 124 + $lang = nonempty( 125 + $this->getEngine()->getConfig('phutil.codeblock.language-default'), 126 + 'text'); 127 + } 128 + $options['lang'] = $lang; 129 + } 130 + 131 + $code_body = $this->highlightSource($text, $options); 132 + 133 + $name_header = null; 134 + $block_style = null; 135 + if ($this->getEngine()->isHTMLMailMode()) { 136 + $map = $this->getEngine()->getConfig('phutil.codeblock.style-map'); 137 + 138 + if ($map) { 139 + $raw_body = id(new PhutilPygmentizeParser()) 140 + ->setMap($map) 141 + ->parse((string)$code_body); 142 + $code_body = phutil_safe_html($raw_body); 143 + } 144 + 145 + $style_rules = array( 146 + 'padding: 6px 12px;', 147 + 'font-size: 13px;', 148 + 'font-weight: bold;', 149 + 'display: inline-block;', 150 + 'border-top-left-radius: 3px;', 151 + 'border-top-right-radius: 3px;', 152 + 'color: rgba(0,0,0,.75);', 153 + ); 154 + 155 + if ($options['counterexample']) { 156 + $style_rules[] = 'background: #f7e6e6'; 157 + } else { 158 + $style_rules[] = 'background: rgba(71, 87, 120, 0.08);'; 159 + } 160 + 161 + $header_attributes = array( 162 + 'style' => implode(' ', $style_rules), 163 + ); 164 + 165 + $block_style = 'margin: 12px 0;'; 166 + } else { 167 + $header_attributes = array( 168 + 'class' => 'remarkup-code-header', 169 + ); 170 + } 171 + 172 + if ($options['name']) { 173 + $name_header = phutil_tag( 174 + 'div', 175 + $header_attributes, 176 + $options['name']); 177 + } 178 + 179 + $class = 'remarkup-code-block'; 180 + if ($options['counterexample']) { 181 + $class = 'remarkup-code-block code-block-counterexample'; 182 + } 183 + 184 + $attributes = array( 185 + 'class' => $class, 186 + 'style' => $block_style, 187 + 'data-code-lang' => $options['lang'], 188 + 'data-sigil' => 'remarkup-code-block', 189 + ); 190 + 191 + return phutil_tag( 192 + 'div', 193 + $attributes, 194 + array($name_header, $code_body)); 195 + } 196 + 197 + private function highlightSource($text, array $options) { 198 + if ($options['counterexample']) { 199 + $aux_class = ' remarkup-counterexample'; 200 + } else { 201 + $aux_class = null; 202 + } 203 + 204 + $aux_style = null; 205 + 206 + if ($this->getEngine()->isHTMLMailMode()) { 207 + $aux_style = array( 208 + 'font: 11px/15px "Menlo", "Consolas", "Monaco", monospace;', 209 + 'padding: 12px;', 210 + 'margin: 0;', 211 + ); 212 + 213 + if ($options['counterexample']) { 214 + $aux_style[] = 'background: #f7e6e6;'; 215 + } else { 216 + $aux_style[] = 'background: rgba(71, 87, 120, 0.08);'; 217 + } 218 + 219 + $aux_style = implode(' ', $aux_style); 220 + } 221 + 222 + if ($options['lines']) { 223 + // Put a minimum size on this because the scrollbar is otherwise 224 + // unusable. 225 + $height = max(6, (int)$options['lines']); 226 + $aux_style = $aux_style 227 + .' ' 228 + .'max-height: ' 229 + .(2 * $height) 230 + .'em; overflow: auto;'; 231 + } 232 + 233 + $engine = $this->getEngine()->getConfig('syntax-highlighter.engine'); 234 + if (!$engine) { 235 + $engine = 'PhutilDefaultSyntaxHighlighterEngine'; 236 + } 237 + $engine = newv($engine, array()); 238 + $engine->setConfig( 239 + 'pygments.enabled', 240 + $this->getEngine()->getConfig('pygments.enabled')); 241 + return phutil_tag( 242 + 'pre', 243 + array( 244 + 'class' => 'remarkup-code'.$aux_class, 245 + 'style' => $aux_style, 246 + ), 247 + PhutilSafeHTML::applyFunction( 248 + 'rtrim', 249 + $engine->highlightSource($options['lang'], $text))); 250 + } 251 + 252 + }
+44
src/infrastructure/markup/blockrule/PhutilRemarkupDefaultBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupDefaultBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getPriority() { 6 + return 750; 7 + } 8 + 9 + public function getMatchingLineCount(array $lines, $cursor) { 10 + return 1; 11 + } 12 + 13 + public function markupText($text, $children) { 14 + $engine = $this->getEngine(); 15 + 16 + $text = trim($text); 17 + $text = $this->applyRules($text); 18 + 19 + if ($engine->isTextMode()) { 20 + if (!$this->getEngine()->getConfig('preserve-linebreaks')) { 21 + $text = preg_replace('/ *\n */', ' ', $text); 22 + } 23 + return $text; 24 + } 25 + 26 + if ($engine->getConfig('preserve-linebreaks')) { 27 + $text = phutil_escape_html_newlines($text); 28 + } 29 + 30 + if (!strlen($text)) { 31 + return null; 32 + } 33 + 34 + $default_attributes = $engine->getConfig('default.p.attributes'); 35 + if ($default_attributes) { 36 + $attributes = $default_attributes; 37 + } else { 38 + $attributes = array(); 39 + } 40 + 41 + return phutil_tag('p', $attributes, $text); 42 + } 43 + 44 + }
+162
src/infrastructure/markup/blockrule/PhutilRemarkupHeaderBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupHeaderBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getMatchingLineCount(array $lines, $cursor) { 6 + $num_lines = 0; 7 + if (preg_match('/^(={1,5}|#{2,5}|# ).*+$/', $lines[$cursor])) { 8 + $num_lines = 1; 9 + } else { 10 + if (isset($lines[$cursor + 1])) { 11 + $line = $lines[$cursor].$lines[$cursor + 1]; 12 + if (preg_match('/^([^\n]+)\n[-=]{2,}\s*$/', $line)) { 13 + $num_lines = 2; 14 + $cursor++; 15 + } 16 + } 17 + } 18 + 19 + if ($num_lines) { 20 + $cursor++; 21 + while (isset($lines[$cursor]) && !strlen(trim($lines[$cursor]))) { 22 + $num_lines++; 23 + $cursor++; 24 + } 25 + } 26 + 27 + return $num_lines; 28 + } 29 + 30 + const KEY_HEADER_TOC = 'headers.toc'; 31 + 32 + public function markupText($text, $children) { 33 + $text = trim($text); 34 + 35 + $lines = phutil_split_lines($text); 36 + if (count($lines) > 1) { 37 + $level = ($lines[1][0] == '=') ? 1 : 2; 38 + $text = trim($lines[0]); 39 + } else { 40 + $level = 0; 41 + for ($ii = 0; $ii < min(5, strlen($text)); $ii++) { 42 + if ($text[$ii] == '=' || $text[$ii] == '#') { 43 + ++$level; 44 + } else { 45 + break; 46 + } 47 + } 48 + $text = trim($text, ' =#'); 49 + } 50 + 51 + $engine = $this->getEngine(); 52 + 53 + if ($engine->isTextMode()) { 54 + $char = ($level == 1) ? '=' : '-'; 55 + return $text."\n".str_repeat($char, phutil_utf8_strlen($text)); 56 + } 57 + 58 + $use_anchors = $engine->getConfig('header.generate-toc'); 59 + 60 + $anchor = null; 61 + if ($use_anchors) { 62 + $anchor = $this->generateAnchor($level, $text); 63 + } 64 + 65 + $text = phutil_tag( 66 + 'h'.($level + 1), 67 + array( 68 + 'class' => 'remarkup-header', 69 + ), 70 + array($anchor, $this->applyRules($text))); 71 + 72 + return $text; 73 + } 74 + 75 + private function generateAnchor($level, $text) { 76 + $anchor = strtolower($text); 77 + $anchor = preg_replace('/[^a-z0-9]/', '-', $anchor); 78 + $anchor = preg_replace('/--+/', '-', $anchor); 79 + $anchor = trim($anchor, '-'); 80 + $anchor = substr($anchor, 0, 24); 81 + $anchor = trim($anchor, '-'); 82 + $base = $anchor; 83 + 84 + $key = self::KEY_HEADER_TOC; 85 + $engine = $this->getEngine(); 86 + $anchors = $engine->getTextMetadata($key, array()); 87 + 88 + $suffix = 1; 89 + while (!strlen($anchor) || isset($anchors[$anchor])) { 90 + $anchor = $base.'-'.$suffix; 91 + $anchor = trim($anchor, '-'); 92 + $suffix++; 93 + } 94 + 95 + // When a document contains a link inside a header, like this: 96 + // 97 + // = [[ http://wwww.example.com/ | example ]] = 98 + // 99 + // ...we want to generate a TOC entry with just "example", but link the 100 + // header itself. We push the 'toc' state so all the link rules generate 101 + // just names. 102 + $engine->pushState('toc'); 103 + $text = $this->applyRules($text); 104 + $text = $engine->restoreText($text); 105 + 106 + $anchors[$anchor] = array($level, $text); 107 + $engine->popState('toc'); 108 + 109 + $engine->setTextMetadata($key, $anchors); 110 + 111 + return phutil_tag( 112 + 'a', 113 + array( 114 + 'name' => $anchor, 115 + ), 116 + ''); 117 + } 118 + 119 + public static function renderTableOfContents(PhutilRemarkupEngine $engine) { 120 + 121 + $key = self::KEY_HEADER_TOC; 122 + $anchors = $engine->getTextMetadata($key, array()); 123 + 124 + if (count($anchors) < 2) { 125 + // Don't generate a TOC if there are no headers, or if there's only 126 + // one header (since such a TOC would be silly). 127 + return null; 128 + } 129 + 130 + $depth = 0; 131 + $toc = array(); 132 + foreach ($anchors as $anchor => $info) { 133 + list($level, $name) = $info; 134 + 135 + while ($depth < $level) { 136 + $toc[] = hsprintf('<ul>'); 137 + $depth++; 138 + } 139 + while ($depth > $level) { 140 + $toc[] = hsprintf('</ul>'); 141 + $depth--; 142 + } 143 + 144 + $toc[] = phutil_tag( 145 + 'li', 146 + array(), 147 + phutil_tag( 148 + 'a', 149 + array( 150 + 'href' => '#'.$anchor, 151 + ), 152 + $name)); 153 + } 154 + while ($depth > 0) { 155 + $toc[] = hsprintf('</ul>'); 156 + $depth--; 157 + } 158 + 159 + return phutil_implode_html("\n", $toc); 160 + } 161 + 162 + }
+37
src/infrastructure/markup/blockrule/PhutilRemarkupHorizontalRuleBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupHorizontalRuleBlockRule 4 + extends PhutilRemarkupBlockRule { 5 + 6 + /** 7 + * This rule executes at priority `300`, so it can preempt the list block 8 + * rule and claim blocks which begin `---`. 9 + */ 10 + public function getPriority() { 11 + return 300; 12 + } 13 + 14 + public function getMatchingLineCount(array $lines, $cursor) { 15 + $num_lines = 0; 16 + $pattern = '/^\s*(?:_{3,}|\*\s?\*\s?\*(\s|\*)*|\-\s?\-\s?\-(\s|\-)*)$/'; 17 + if (preg_match($pattern, rtrim($lines[$cursor], "\n\r"))) { 18 + $num_lines++; 19 + $cursor++; 20 + while (isset($lines[$cursor]) && !strlen(trim($lines[$cursor]))) { 21 + $num_lines++; 22 + $cursor++; 23 + } 24 + } 25 + 26 + return $num_lines; 27 + } 28 + 29 + public function markupText($text, $children) { 30 + if ($this->getEngine()->isTextMode()) { 31 + return rtrim($text); 32 + } 33 + 34 + return phutil_tag('hr', array('class' => 'remarkup-hr')); 35 + } 36 + 37 + }
+13
src/infrastructure/markup/blockrule/PhutilRemarkupInlineBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupInlineBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getMatchingLineCount(array $lines, $cursor) { 6 + return 1; 7 + } 8 + 9 + public function markupText($text, $children) { 10 + return $this->applyRules($text); 11 + } 12 + 13 + }
+89
src/infrastructure/markup/blockrule/PhutilRemarkupInterpreterBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupInterpreterBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + const START_BLOCK_PATTERN = '/^([\w]+)\s*(?:\(([^)]+)\)\s*)?{{{/'; 6 + const END_BLOCK_PATTERN = '/}}}\s*$/'; 7 + 8 + public function getMatchingLineCount(array $lines, $cursor) { 9 + $num_lines = 0; 10 + 11 + if (preg_match(self::START_BLOCK_PATTERN, $lines[$cursor])) { 12 + $num_lines++; 13 + 14 + while (isset($lines[$cursor])) { 15 + if (preg_match(self::END_BLOCK_PATTERN, $lines[$cursor])) { 16 + break; 17 + } 18 + $num_lines++; 19 + $cursor++; 20 + } 21 + } 22 + 23 + return $num_lines; 24 + } 25 + 26 + public function markupText($text, $children) { 27 + $lines = explode("\n", $text); 28 + $first_key = head_key($lines); 29 + $last_key = last_key($lines); 30 + while (trim($lines[$last_key]) === '') { 31 + unset($lines[$last_key]); 32 + $last_key = last_key($lines); 33 + } 34 + $matches = null; 35 + 36 + preg_match(self::START_BLOCK_PATTERN, head($lines), $matches); 37 + 38 + $argv = array(); 39 + if (isset($matches[2])) { 40 + $argv = id(new PhutilSimpleOptions())->parse($matches[2]); 41 + } 42 + 43 + $interpreters = id(new PhutilClassMapQuery()) 44 + ->setAncestorClass('PhutilRemarkupBlockInterpreter') 45 + ->execute(); 46 + 47 + foreach ($interpreters as $interpreter) { 48 + $interpreter->setEngine($this->getEngine()); 49 + } 50 + 51 + $lines[$first_key] = preg_replace( 52 + self::START_BLOCK_PATTERN, 53 + '', 54 + $lines[$first_key]); 55 + $lines[$last_key] = preg_replace( 56 + self::END_BLOCK_PATTERN, 57 + '', 58 + $lines[$last_key]); 59 + 60 + if (trim($lines[$first_key]) === '') { 61 + unset($lines[$first_key]); 62 + } 63 + if (trim($lines[$last_key]) === '') { 64 + unset($lines[$last_key]); 65 + } 66 + 67 + $content = implode("\n", $lines); 68 + 69 + $interpreters = mpull($interpreters, null, 'getInterpreterName'); 70 + 71 + if (isset($interpreters[$matches[1]])) { 72 + return $interpreters[$matches[1]]->markupContent($content, $argv); 73 + } 74 + 75 + $message = pht('No interpreter found: %s', $matches[1]); 76 + 77 + if ($this->getEngine()->isTextMode()) { 78 + return '('.$message.')'; 79 + } 80 + 81 + return phutil_tag( 82 + 'div', 83 + array( 84 + 'class' => 'remarkup-interpreter-error', 85 + ), 86 + $message); 87 + } 88 + 89 + }
+567
src/infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupListBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + /** 6 + * This rule must apply before the Code block rule because it needs to 7 + * win blocks which begin ` - Lorem ipsum`. 8 + */ 9 + public function getPriority() { 10 + return 400; 11 + } 12 + 13 + public function getMatchingLineCount(array $lines, $cursor) { 14 + $num_lines = 0; 15 + 16 + $first_line = $cursor; 17 + $is_one_line = false; 18 + while (isset($lines[$cursor])) { 19 + if (!$num_lines) { 20 + if (preg_match(self::START_BLOCK_PATTERN, $lines[$cursor])) { 21 + $num_lines++; 22 + $cursor++; 23 + $is_one_line = true; 24 + continue; 25 + } 26 + } else { 27 + if (preg_match(self::CONT_BLOCK_PATTERN, $lines[$cursor])) { 28 + $num_lines++; 29 + $cursor++; 30 + $is_one_line = false; 31 + continue; 32 + } 33 + 34 + // Allow lists to continue across multiple paragraphs, as long as lines 35 + // are indented or a single empty line separates indented lines. 36 + 37 + $this_empty = !strlen(trim($lines[$cursor])); 38 + $this_indented = preg_match('/^ /', $lines[$cursor]); 39 + 40 + $next_empty = true; 41 + $next_indented = false; 42 + if (isset($lines[$cursor + 1])) { 43 + $next_empty = !strlen(trim($lines[$cursor + 1])); 44 + $next_indented = preg_match('/^ /', $lines[$cursor + 1]); 45 + } 46 + 47 + if ($this_empty || $this_indented) { 48 + if (($this_indented && !$this_empty) || 49 + ($next_indented && !$next_empty)) { 50 + $num_lines++; 51 + $cursor++; 52 + continue; 53 + } 54 + } 55 + 56 + if ($this_empty) { 57 + $num_lines++; 58 + } 59 + } 60 + 61 + break; 62 + } 63 + 64 + // If this list only has one item in it, and the list marker is "#", and 65 + // it's not the last line in the input, parse it as a header instead of a 66 + // list. This produces better behavior for alternate Markdown headers. 67 + 68 + if ($is_one_line) { 69 + if (($first_line + $num_lines) < count($lines)) { 70 + if (strncmp($lines[$first_line], '#', 1) === 0) { 71 + return 0; 72 + } 73 + } 74 + } 75 + 76 + return $num_lines; 77 + } 78 + 79 + /** 80 + * The maximum sub-list depth you can nest to. Avoids silliness and blowing 81 + * the stack. 82 + */ 83 + const MAXIMUM_LIST_NESTING_DEPTH = 12; 84 + const START_BLOCK_PATTERN = '@^\s*(?:[-*#]+|([1-9][0-9]*)[.)]|\[\D?\])\s+@'; 85 + const CONT_BLOCK_PATTERN = '@^\s*(?:[-*#]+|[0-9]+[.)]|\[\D?\])\s+@'; 86 + const STRIP_BLOCK_PATTERN = '@^\s*(?:[-*#]+|[0-9]+[.)])\s*@'; 87 + 88 + public function markupText($text, $children) { 89 + $items = array(); 90 + $lines = explode("\n", $text); 91 + 92 + // We allow users to delimit lists using either differing indentation 93 + // levels: 94 + // 95 + // - a 96 + // - b 97 + // 98 + // ...or differing numbers of item-delimiter characters: 99 + // 100 + // - a 101 + // -- b 102 + // 103 + // If they use the second style but block-indent the whole list, we'll 104 + // get the depth counts wrong for the first item. To prevent this, 105 + // un-indent every item by the minimum indentation level for the whole 106 + // block before we begin parsing. 107 + 108 + $regex = self::START_BLOCK_PATTERN; 109 + $min_space = PHP_INT_MAX; 110 + foreach ($lines as $ii => $line) { 111 + $matches = null; 112 + if (preg_match($regex, $line)) { 113 + $regex = self::CONT_BLOCK_PATTERN; 114 + if (preg_match('/^(\s+)/', $line, $matches)) { 115 + $space = strlen($matches[1]); 116 + } else { 117 + $space = 0; 118 + } 119 + $min_space = min($min_space, $space); 120 + } 121 + } 122 + 123 + $regex = self::START_BLOCK_PATTERN; 124 + if ($min_space) { 125 + foreach ($lines as $key => $line) { 126 + if (preg_match($regex, $line)) { 127 + $regex = self::CONT_BLOCK_PATTERN; 128 + $lines[$key] = substr($line, $min_space); 129 + } 130 + } 131 + } 132 + 133 + 134 + // The input text may have linewraps in it, like this: 135 + // 136 + // - derp derp derp derp 137 + // derp derp derp derp 138 + // - blarp blarp blarp blarp 139 + // 140 + // Group text lines together into list items, stored in $items. So the 141 + // result in the above case will be: 142 + // 143 + // array( 144 + // array( 145 + // "- derp derp derp derp", 146 + // " derp derp derp derp", 147 + // ), 148 + // array( 149 + // "- blarp blarp blarp blarp", 150 + // ), 151 + // ); 152 + 153 + $item = array(); 154 + $starts_at = null; 155 + $regex = self::START_BLOCK_PATTERN; 156 + foreach ($lines as $line) { 157 + $match = null; 158 + if (preg_match($regex, $line, $match)) { 159 + if (!$starts_at && !empty($match[1])) { 160 + $starts_at = $match[1]; 161 + } 162 + $regex = self::CONT_BLOCK_PATTERN; 163 + if ($item) { 164 + $items[] = $item; 165 + $item = array(); 166 + } 167 + } 168 + $item[] = $line; 169 + } 170 + if ($item) { 171 + $items[] = $item; 172 + } 173 + if (!$starts_at) { 174 + $starts_at = 1; 175 + } 176 + 177 + 178 + // Process each item to normalize the text, remove line wrapping, and 179 + // determine its depth (indentation level) and style (ordered vs unordered). 180 + // 181 + // We preserve consecutive linebreaks and interpret them as paragraph 182 + // breaks. 183 + // 184 + // Given the above example, the processed array will look like: 185 + // 186 + // array( 187 + // array( 188 + // 'text' => 'derp derp derp derp derp derp derp derp', 189 + // 'depth' => 0, 190 + // 'style' => '-', 191 + // ), 192 + // array( 193 + // 'text' => 'blarp blarp blarp blarp', 194 + // 'depth' => 0, 195 + // 'style' => '-', 196 + // ), 197 + // ); 198 + 199 + $has_marks = false; 200 + foreach ($items as $key => $item) { 201 + // Trim space around newlines, to strip trailing whitespace and formatting 202 + // indentation. 203 + $item = preg_replace('/ *(\n+) */', '\1', implode("\n", $item)); 204 + 205 + // Replace single newlines with a space. Preserve multiple newlines as 206 + // paragraph breaks. 207 + $item = preg_replace('/(?<!\n)\n(?!\n)/', ' ', $item); 208 + 209 + $item = rtrim($item); 210 + 211 + if (!strlen($item)) { 212 + unset($items[$key]); 213 + continue; 214 + } 215 + 216 + $matches = null; 217 + if (preg_match('/^\s*([-*#]{2,})/', $item, $matches)) { 218 + // Alternate-style indents; use number of list item symbols. 219 + $depth = strlen($matches[1]) - 1; 220 + } else if (preg_match('/^(\s+)/', $item, $matches)) { 221 + // Markdown-style indents; use indent depth. 222 + $depth = strlen($matches[1]); 223 + } else { 224 + $depth = 0; 225 + } 226 + 227 + if (preg_match('/^\s*(?:#|[0-9])/', $item)) { 228 + $style = '#'; 229 + } else { 230 + $style = '-'; 231 + } 232 + 233 + // Strip leading indicators off the item. 234 + $text = preg_replace(self::STRIP_BLOCK_PATTERN, '', $item); 235 + 236 + // Look for "[]", "[ ]", "[*]", "[x]", etc., which we render as a 237 + // checkbox. We don't render [1], [2], etc., as checkboxes, as these 238 + // are often used as footnotes. 239 + $mark = null; 240 + $matches = null; 241 + if (preg_match('/^\s*\[(\D?)\]\s*/', $text, $matches)) { 242 + if (strlen(trim($matches[1]))) { 243 + $mark = true; 244 + } else { 245 + $mark = false; 246 + } 247 + $has_marks = true; 248 + $text = substr($text, strlen($matches[0])); 249 + } 250 + 251 + $items[$key] = array( 252 + 'text' => $text, 253 + 'depth' => $depth, 254 + 'style' => $style, 255 + 'mark' => $mark, 256 + ); 257 + } 258 + $items = array_values($items); 259 + 260 + 261 + // Users can create a sub-list by indenting any deeper amount than the 262 + // previous list, so these are both valid: 263 + // 264 + // - a 265 + // - b 266 + // 267 + // - a 268 + // - b 269 + // 270 + // In the former case, we'll have depths (0, 2). In the latter case, depths 271 + // (0, 4). We don't actually care about how many spaces there are, only 272 + // how many list indentation levels (that is, we want to map both of 273 + // those cases to (0, 1), indicating "outermost list" and "first sublist"). 274 + // 275 + // This is made more complicated because lists at two different indentation 276 + // levels might be at the same list level: 277 + // 278 + // - a 279 + // - b 280 + // - c 281 + // - d 282 + // 283 + // Here, 'b' and 'd' are at the same list level (2) but different indent 284 + // levels (2, 4). 285 + // 286 + // Users can also create "staircases" like this: 287 + // 288 + // - a 289 + // - b 290 + // # c 291 + // 292 + // While this is silly, we'd like to render it as faithfully as possible. 293 + // 294 + // In order to do this, we convert the list of nodes into a tree, 295 + // normalizing indentation levels and inserting dummy nodes as necessary to 296 + // make the tree well-formed. See additional notes at buildTree(). 297 + // 298 + // In the case above, the result is a tree like this: 299 + // 300 + // - <null> 301 + // - <null> 302 + // - a 303 + // - b 304 + // # c 305 + 306 + $l = 0; 307 + $r = count($items); 308 + $tree = $this->buildTree($items, $l, $r, $cur_level = 0); 309 + 310 + 311 + // We may need to open a list on a <null> node, but they do not have 312 + // list style information yet. We need to propagate list style information 313 + // backward through the tree. In the above example, the tree now looks 314 + // like this: 315 + // 316 + // - <null (style=#)> 317 + // - <null (style=-)> 318 + // - a 319 + // - b 320 + // # c 321 + 322 + $this->adjustTreeStyleInformation($tree); 323 + 324 + // Finally, we have enough information to render the tree. 325 + 326 + $out = $this->renderTree($tree, 0, $has_marks, $starts_at); 327 + 328 + if ($this->getEngine()->isTextMode()) { 329 + $out = implode('', $out); 330 + $out = rtrim($out, "\n"); 331 + $out = preg_replace('/ +$/m', '', $out); 332 + return $out; 333 + } 334 + 335 + return phutil_implode_html('', $out); 336 + } 337 + 338 + /** 339 + * See additional notes in @{method:markupText}. 340 + */ 341 + private function buildTree(array $items, $l, $r, $cur_level) { 342 + if ($l == $r) { 343 + return array(); 344 + } 345 + 346 + if ($cur_level > self::MAXIMUM_LIST_NESTING_DEPTH) { 347 + // This algorithm is recursive and we don't need you blowing the stack 348 + // with your oh-so-clever 50,000-item-deep list. Cap indentation levels 349 + // at a reasonable number and just shove everything deeper up to this 350 + // level. 351 + $nodes = array(); 352 + for ($ii = $l; $ii < $r; $ii++) { 353 + $nodes[] = array( 354 + 'level' => $cur_level, 355 + 'items' => array(), 356 + ) + $items[$ii]; 357 + } 358 + return $nodes; 359 + } 360 + 361 + $min = $l; 362 + for ($ii = $r - 1; $ii >= $l; $ii--) { 363 + if ($items[$ii]['depth'] <= $items[$min]['depth']) { 364 + $min = $ii; 365 + } 366 + } 367 + 368 + $min_depth = $items[$min]['depth']; 369 + 370 + $nodes = array(); 371 + if ($min != $l) { 372 + $nodes[] = array( 373 + 'text' => null, 374 + 'level' => $cur_level, 375 + 'style' => null, 376 + 'mark' => null, 377 + 'items' => $this->buildTree($items, $l, $min, $cur_level + 1), 378 + ); 379 + } 380 + 381 + $last = $min; 382 + for ($ii = $last + 1; $ii < $r; $ii++) { 383 + if ($items[$ii]['depth'] == $min_depth) { 384 + $nodes[] = array( 385 + 'level' => $cur_level, 386 + 'items' => $this->buildTree($items, $last + 1, $ii, $cur_level + 1), 387 + ) + $items[$last]; 388 + $last = $ii; 389 + } 390 + } 391 + $nodes[] = array( 392 + 'level' => $cur_level, 393 + 'items' => $this->buildTree($items, $last + 1, $r, $cur_level + 1), 394 + ) + $items[$last]; 395 + 396 + return $nodes; 397 + } 398 + 399 + 400 + /** 401 + * See additional notes in @{method:markupText}. 402 + */ 403 + private function adjustTreeStyleInformation(array &$tree) { 404 + // The effect here is just to walk backward through the nodes at this level 405 + // and apply the first style in the list to any empty nodes we inserted 406 + // before it. As we go, also recurse down the tree. 407 + 408 + $style = '-'; 409 + for ($ii = count($tree) - 1; $ii >= 0; $ii--) { 410 + if ($tree[$ii]['style'] !== null) { 411 + // This is the earliest node we've seen with style, so set the 412 + // style to its style. 413 + $style = $tree[$ii]['style']; 414 + } else { 415 + // This node has no style, so apply the current style. 416 + $tree[$ii]['style'] = $style; 417 + } 418 + if ($tree[$ii]['items']) { 419 + $this->adjustTreeStyleInformation($tree[$ii]['items']); 420 + } 421 + } 422 + } 423 + 424 + 425 + /** 426 + * See additional notes in @{method:markupText}. 427 + */ 428 + private function renderTree( 429 + array $tree, 430 + $level, 431 + $has_marks, 432 + $starts_at = 1) { 433 + 434 + $style = idx(head($tree), 'style'); 435 + 436 + $out = array(); 437 + 438 + if (!$this->getEngine()->isTextMode()) { 439 + switch ($style) { 440 + case '#': 441 + $tag = 'ol'; 442 + break; 443 + case '-': 444 + $tag = 'ul'; 445 + break; 446 + } 447 + 448 + $start_attr = null; 449 + if (ctype_digit($starts_at) && $starts_at > 1) { 450 + $start_attr = hsprintf(' start="%d"', $starts_at); 451 + } 452 + 453 + if ($has_marks) { 454 + $out[] = hsprintf( 455 + '<%s class="remarkup-list remarkup-list-with-checkmarks"%s>', 456 + $tag, 457 + $start_attr); 458 + } else { 459 + $out[] = hsprintf( 460 + '<%s class="remarkup-list"%s>', 461 + $tag, 462 + $start_attr); 463 + } 464 + 465 + $out[] = "\n"; 466 + } 467 + 468 + $number = $starts_at; 469 + foreach ($tree as $item) { 470 + if ($this->getEngine()->isTextMode()) { 471 + if ($item['text'] === null) { 472 + // Don't render anything. 473 + } else { 474 + $indent = str_repeat(' ', 2 * $level); 475 + $out[] = $indent; 476 + if ($item['mark'] !== null) { 477 + if ($item['mark']) { 478 + $out[] = '[X] '; 479 + } else { 480 + $out[] = '[ ] '; 481 + } 482 + } else { 483 + switch ($style) { 484 + case '#': 485 + $out[] = $number.'. '; 486 + $number++; 487 + break; 488 + case '-': 489 + $out[] = '- '; 490 + break; 491 + } 492 + } 493 + 494 + $parts = preg_split('/\n{2,}/', $item['text']); 495 + foreach ($parts as $key => $part) { 496 + if ($key != 0) { 497 + $out[] = "\n\n ".$indent; 498 + } 499 + $out[] = $this->applyRules($part); 500 + } 501 + $out[] = "\n"; 502 + } 503 + } else { 504 + if ($item['text'] === null) { 505 + $out[] = hsprintf('<li class="remarkup-list-item phantom-item">'); 506 + } else { 507 + if ($item['mark'] !== null) { 508 + if ($item['mark'] == true) { 509 + $out[] = hsprintf( 510 + '<li class="remarkup-list-item remarkup-checked-item">'); 511 + } else { 512 + $out[] = hsprintf( 513 + '<li class="remarkup-list-item remarkup-unchecked-item">'); 514 + } 515 + $out[] = phutil_tag( 516 + 'input', 517 + array( 518 + 'type' => 'checkbox', 519 + 'checked' => ($item['mark'] ? 'checked' : null), 520 + 'disabled' => 'disabled', 521 + )); 522 + $out[] = ' '; 523 + } else { 524 + $out[] = hsprintf('<li class="remarkup-list-item">'); 525 + } 526 + 527 + $parts = preg_split('/\n{2,}/', $item['text']); 528 + foreach ($parts as $key => $part) { 529 + if ($key != 0) { 530 + $out[] = array( 531 + "\n", 532 + phutil_tag('br'), 533 + phutil_tag('br'), 534 + "\n", 535 + ); 536 + } 537 + $out[] = $this->applyRules($part); 538 + } 539 + } 540 + } 541 + 542 + if ($item['items']) { 543 + $subitems = $this->renderTree($item['items'], $level + 1, $has_marks); 544 + foreach ($subitems as $i) { 545 + $out[] = $i; 546 + } 547 + } 548 + if (!$this->getEngine()->isTextMode()) { 549 + $out[] = hsprintf("</li>\n"); 550 + } 551 + } 552 + 553 + if (!$this->getEngine()->isTextMode()) { 554 + switch ($style) { 555 + case '#': 556 + $out[] = hsprintf('</ol>'); 557 + break; 558 + case '-': 559 + $out[] = hsprintf('</ul>'); 560 + break; 561 + } 562 + } 563 + 564 + return $out; 565 + } 566 + 567 + }
+93
src/infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupLiteralBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getPriority() { 6 + return 450; 7 + } 8 + 9 + public function getMatchingLineCount(array $lines, $cursor) { 10 + // NOTE: We're consuming all continguous blocks of %%% literals, so this: 11 + // 12 + // %%%a%%% 13 + // %%%b%%% 14 + // 15 + // ...is equivalent to: 16 + // 17 + // %%%a 18 + // b%%% 19 + // 20 + // If they are separated by a blank newline, they are parsed as two 21 + // different blocks. This more clearly represents the original text in the 22 + // output text and assists automated escaping of blocks coming into the 23 + // system. 24 + 25 + $num_lines = 0; 26 + while (preg_match('/^\s*%%%/', $lines[$cursor])) { 27 + $num_lines++; 28 + 29 + // If the line has ONLY "%%%", the block opener doesn't get to double 30 + // up as a block terminator. 31 + if (preg_match('/^\s*%%%\s*\z/', $lines[$cursor])) { 32 + $num_lines++; 33 + $cursor++; 34 + } 35 + 36 + while (isset($lines[$cursor])) { 37 + if (!preg_match('/%%%\s*$/', $lines[$cursor])) { 38 + $num_lines++; 39 + $cursor++; 40 + continue; 41 + } 42 + break; 43 + } 44 + 45 + $cursor++; 46 + 47 + $found_empty = false; 48 + while (isset($lines[$cursor])) { 49 + if (!strlen(trim($lines[$cursor]))) { 50 + $num_lines++; 51 + $cursor++; 52 + $found_empty = true; 53 + continue; 54 + } 55 + break; 56 + } 57 + 58 + if ($found_empty) { 59 + // If there's an empty line after the block, stop merging blocks. 60 + break; 61 + } 62 + 63 + if (!isset($lines[$cursor])) { 64 + // If we're at the end of the input, stop looking for more lines. 65 + break; 66 + } 67 + } 68 + 69 + return $num_lines; 70 + } 71 + 72 + public function markupText($text, $children) { 73 + $text = rtrim($text); 74 + $text = phutil_split_lines($text, $retain_endings = true); 75 + foreach ($text as $key => $line) { 76 + $line = preg_replace('/^\s*%%%/', '', $line); 77 + $line = preg_replace('/%%%(\s*)\z/', '\1', $line); 78 + $text[$key] = $line; 79 + } 80 + 81 + if ($this->getEngine()->isTextMode()) { 82 + return implode('', $text); 83 + } 84 + 85 + return phutil_tag( 86 + 'p', 87 + array( 88 + 'class' => 'remarkup-literal', 89 + ), 90 + phutil_implode_html(phutil_tag('br', array()), $text)); 91 + } 92 + 93 + }
+121
src/infrastructure/markup/blockrule/PhutilRemarkupNoteBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupNoteBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getMatchingLineCount(array $lines, $cursor) { 6 + $num_lines = 0; 7 + 8 + if (preg_match($this->getRegEx(), $lines[$cursor])) { 9 + $num_lines++; 10 + $cursor++; 11 + 12 + while (isset($lines[$cursor])) { 13 + if (trim($lines[$cursor])) { 14 + $num_lines++; 15 + $cursor++; 16 + continue; 17 + } 18 + break; 19 + } 20 + } 21 + 22 + return $num_lines; 23 + } 24 + 25 + public function markupText($text, $children) { 26 + $matches = array(); 27 + preg_match($this->getRegEx(), $text, $matches); 28 + 29 + if (idx($matches, 'showword')) { 30 + $word = $matches['showword']; 31 + $show = true; 32 + } else { 33 + $word = $matches['hideword']; 34 + $show = false; 35 + } 36 + 37 + $class_suffix = phutil_utf8_strtolower($word); 38 + 39 + // This is the "(IMPORTANT)" or "NOTE:" part. 40 + $word_part = rtrim(substr($text, 0, strlen($matches[0]))); 41 + 42 + // This is the actual text. 43 + $text_part = substr($text, strlen($matches[0])); 44 + $text_part = $this->applyRules(rtrim($text_part)); 45 + 46 + $text_mode = $this->getEngine()->isTextMode(); 47 + $html_mail_mode = $this->getEngine()->isHTMLMailMode(); 48 + if ($text_mode) { 49 + return $word_part.' '.$text_part; 50 + } 51 + 52 + if ($show) { 53 + $content = array( 54 + phutil_tag( 55 + 'span', 56 + array( 57 + 'class' => 'remarkup-note-word', 58 + ), 59 + $word_part), 60 + ' ', 61 + $text_part, 62 + ); 63 + } else { 64 + $content = $text_part; 65 + } 66 + 67 + if ($html_mail_mode) { 68 + if ($class_suffix == 'important') { 69 + $attributes = array( 70 + 'style' => 'margin: 16px 0; 71 + padding: 12px; 72 + border-left: 3px solid #c0392b; 73 + background: #f4dddb;', 74 + ); 75 + } else if ($class_suffix == 'note') { 76 + $attributes = array( 77 + 'style' => 'margin: 16px 0; 78 + padding: 12px; 79 + border-left: 3px solid #2980b9; 80 + background: #daeaf3;', 81 + ); 82 + } else if ($class_suffix == 'warning') { 83 + $attributes = array( 84 + 'style' => 'margin: 16px 0; 85 + padding: 12px; 86 + border-left: 3px solid #f1c40f; 87 + background: #fdf5d4;', 88 + ); 89 + } 90 + } else { 91 + $attributes = array( 92 + 'class' => 'remarkup-'.$class_suffix, 93 + ); 94 + } 95 + 96 + return phutil_tag( 97 + 'div', 98 + $attributes, 99 + $content); 100 + } 101 + 102 + private function getRegEx() { 103 + $words = array( 104 + 'NOTE', 105 + 'IMPORTANT', 106 + 'WARNING', 107 + ); 108 + 109 + foreach ($words as $k => $word) { 110 + $words[$k] = preg_quote($word, '/'); 111 + } 112 + $words = implode('|', $words); 113 + 114 + return 115 + '/^(?:'. 116 + '(?:\((?P<hideword>'.$words.')\))'. 117 + '|'. 118 + '(?:(?P<showword>'.$words.'):))\s*'. 119 + '/'; 120 + } 121 + }
+108
src/infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php
··· 1 + <?php 2 + 3 + abstract class PhutilRemarkupQuotedBlockRule 4 + extends PhutilRemarkupBlockRule { 5 + 6 + final public function supportsChildBlocks() { 7 + return true; 8 + } 9 + 10 + final protected function normalizeQuotedBody($text) { 11 + $text = phutil_split_lines($text, true); 12 + foreach ($text as $key => $line) { 13 + $text[$key] = substr($line, 1); 14 + } 15 + 16 + // If every line in the block is empty or begins with at least one leading 17 + // space, strip the initial space off each line. When we quote text, we 18 + // normally add "> " (with a space) to the beginning of each line, which 19 + // can disrupt some other rules. If the block appears to have this space 20 + // in front of each line, remove it. 21 + 22 + $strip_space = true; 23 + foreach ($text as $key => $line) { 24 + $len = strlen($line); 25 + 26 + if (!$len) { 27 + // We'll still strip spaces if there are some completely empty 28 + // lines, they may have just had trailing whitespace trimmed. 29 + continue; 30 + } 31 + 32 + // If this line is part of a nested quote block, just ignore it when 33 + // realigning this quote block. It's either an author attribution 34 + // line with ">>!", or we'll deal with it in a subrule when processing 35 + // the nested quote block. 36 + if ($line[0] == '>') { 37 + continue; 38 + } 39 + 40 + if ($line[0] == ' ' || $line[0] == "\n") { 41 + continue; 42 + } 43 + 44 + // The first character of this line is something other than a space, so 45 + // we can't strip spaces. 46 + $strip_space = false; 47 + break; 48 + } 49 + 50 + if ($strip_space) { 51 + foreach ($text as $key => $line) { 52 + $len = strlen($line); 53 + if (!$len) { 54 + continue; 55 + } 56 + 57 + if ($line[0] !== ' ') { 58 + continue; 59 + } 60 + 61 + $text[$key] = substr($line, 1); 62 + } 63 + } 64 + 65 + // Strip leading empty lines. 66 + foreach ($text as $key => $line) { 67 + if (!strlen(trim($line))) { 68 + unset($text[$key]); 69 + } 70 + } 71 + 72 + return implode('', $text); 73 + } 74 + 75 + final protected function getQuotedText($text) { 76 + $text = rtrim($text, "\n"); 77 + 78 + $no_whitespace = array( 79 + // For readability, we render nested quotes as ">> quack", 80 + // not "> > quack". 81 + '>' => true, 82 + 83 + // If the line is empty except for a newline, do not add an 84 + // unnecessary dangling space. 85 + "\n" => true, 86 + ); 87 + 88 + $text = phutil_split_lines($text, true); 89 + foreach ($text as $key => $line) { 90 + $c = null; 91 + if (isset($line[0])) { 92 + $c = $line[0]; 93 + } else { 94 + $c = null; 95 + } 96 + 97 + if (isset($no_whitespace[$c])) { 98 + $text[$key] = '>'.$line; 99 + } else { 100 + $text[$key] = '> '.$line; 101 + } 102 + } 103 + $text = implode('', $text); 104 + 105 + return $text; 106 + } 107 + 108 + }
+47
src/infrastructure/markup/blockrule/PhutilRemarkupQuotesBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupQuotesBlockRule 4 + extends PhutilRemarkupQuotedBlockRule { 5 + 6 + public function getMatchingLineCount(array $lines, $cursor) { 7 + $pos = $cursor; 8 + 9 + if (preg_match('/^>/', $lines[$pos])) { 10 + do { 11 + ++$pos; 12 + } while (isset($lines[$pos]) && preg_match('/^>/', $lines[$pos])); 13 + } 14 + 15 + return ($pos - $cursor); 16 + } 17 + 18 + public function extractChildText($text) { 19 + return array('', $this->normalizeQuotedBody($text)); 20 + } 21 + 22 + public function markupText($text, $children) { 23 + if ($this->getEngine()->isTextMode()) { 24 + return $this->getQuotedText($children); 25 + } 26 + 27 + $attributes = array(); 28 + if ($this->getEngine()->isHTMLMailMode()) { 29 + $style = array( 30 + 'border-left: 3px solid #a7b5bf;', 31 + 'color: #464c5c;', 32 + 'font-style: italic;', 33 + 'margin: 4px 0 12px 0;', 34 + 'padding: 4px 12px;', 35 + 'background-color: #f8f9fc;', 36 + ); 37 + 38 + $attributes['style'] = implode(' ', $style); 39 + } 40 + 41 + return phutil_tag( 42 + 'blockquote', 43 + $attributes, 44 + $children); 45 + } 46 + 47 + }
+91
src/infrastructure/markup/blockrule/PhutilRemarkupReplyBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupReplyBlockRule 4 + extends PhutilRemarkupQuotedBlockRule { 5 + 6 + public function getPriority() { 7 + return 400; 8 + } 9 + 10 + public function getMatchingLineCount(array $lines, $cursor) { 11 + $pos = $cursor; 12 + 13 + if (preg_match('/^>>!/', $lines[$pos])) { 14 + do { 15 + ++$pos; 16 + } while (isset($lines[$pos]) && preg_match('/^>/', $lines[$pos])); 17 + } 18 + 19 + return ($pos - $cursor); 20 + } 21 + 22 + public function extractChildText($text) { 23 + $text = phutil_split_lines($text, true); 24 + 25 + $head = substr(reset($text), 3); 26 + 27 + $body = array_slice($text, 1); 28 + $body = implode('', $body); 29 + $body = $this->normalizeQuotedBody($body); 30 + 31 + return array(trim($head), $body); 32 + } 33 + 34 + public function markupText($text, $children) { 35 + $text = $this->applyRules($text); 36 + 37 + if ($this->getEngine()->isTextMode()) { 38 + $children = $this->getQuotedText($children); 39 + return $text."\n\n".$children; 40 + } 41 + 42 + if ($this->getEngine()->isHTMLMailMode()) { 43 + $block_attributes = array( 44 + 'style' => 'border-left: 3px solid #8C98B8; 45 + color: #6B748C; 46 + font-style: italic; 47 + margin: 4px 0 12px 0; 48 + padding: 8px 12px; 49 + background-color: #F8F9FC;', 50 + ); 51 + $head_attributes = array( 52 + 'style' => 'font-style: normal; 53 + padding-bottom: 4px;', 54 + ); 55 + $reply_attributes = array( 56 + 'style' => 'margin: 0; 57 + padding: 0; 58 + border: 0; 59 + color: rgb(107, 116, 140);', 60 + ); 61 + } else { 62 + $block_attributes = array( 63 + 'class' => 'remarkup-reply-block', 64 + ); 65 + $head_attributes = array( 66 + 'class' => 'remarkup-reply-head', 67 + ); 68 + $reply_attributes = array( 69 + 'class' => 'remarkup-reply-body', 70 + ); 71 + } 72 + 73 + return phutil_tag( 74 + 'blockquote', 75 + $block_attributes, 76 + array( 77 + "\n", 78 + phutil_tag( 79 + 'div', 80 + $head_attributes, 81 + $text), 82 + "\n", 83 + phutil_tag( 84 + 'div', 85 + $reply_attributes, 86 + $children), 87 + "\n", 88 + )); 89 + } 90 + 91 + }
+96
src/infrastructure/markup/blockrule/PhutilRemarkupSimpleTableBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupSimpleTableBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getMatchingLineCount(array $lines, $cursor) { 6 + $num_lines = 0; 7 + while (isset($lines[$cursor])) { 8 + if (preg_match('/^(\s*\|.*+\n?)+$/', $lines[$cursor])) { 9 + $num_lines++; 10 + $cursor++; 11 + } else { 12 + break; 13 + } 14 + } 15 + 16 + return $num_lines; 17 + } 18 + 19 + public function markupText($text, $children) { 20 + $matches = array(); 21 + 22 + $rows = array(); 23 + foreach (explode("\n", $text) as $line) { 24 + // Ignore ending delimiters. 25 + $line = rtrim($line, '|'); 26 + 27 + // NOTE: The complexity in this regular expression allows us to match 28 + // a table like "| a | [[ href | b ]] | c |". 29 + 30 + preg_match_all( 31 + '/\|'. 32 + '('. 33 + '(?:'. 34 + '(?:\\[\\[.*?\\]\\])'. // [[ ... | ... ]], a link 35 + '|'. 36 + '(?:[^|[]+)'. // Anything but "|" or "[". 37 + '|'. 38 + '(?:\\[[^\\|[])'. // "[" followed by anything but "[" or "|" 39 + ')*'. 40 + ')/', $line, $matches); 41 + 42 + $any_header = false; 43 + $any_content = false; 44 + 45 + $cells = array(); 46 + foreach ($matches[1] as $cell) { 47 + $cell = trim($cell); 48 + 49 + // If this row only has empty cells and "--" cells, and it has at 50 + // least one "--" cell, it's marking the rows above as <th> cells 51 + // instead of <td> cells. 52 + 53 + // If it has other types of cells, it's always a content row. 54 + 55 + // If it has only empty cells, it's an empty row. 56 + 57 + if (strlen($cell)) { 58 + if (preg_match('/^--+\z/', $cell)) { 59 + $any_header = true; 60 + } else { 61 + $any_content = true; 62 + } 63 + } 64 + 65 + $cells[] = array('type' => 'td', 'content' => $this->applyRules($cell)); 66 + } 67 + 68 + $is_header = ($any_header && !$any_content); 69 + 70 + if (!$is_header) { 71 + $rows[] = array('type' => 'tr', 'content' => $cells); 72 + } else if ($rows) { 73 + // Mark previous row with headings. 74 + foreach ($cells as $i => $cell) { 75 + if ($cell['content']) { 76 + $last_key = last_key($rows); 77 + if (!isset($rows[$last_key]['content'][$i])) { 78 + // If this row has more cells than the previous row, there may 79 + // not be a cell above this one to turn into a <th />. 80 + continue; 81 + } 82 + 83 + $rows[$last_key]['content'][$i]['type'] = 'th'; 84 + } 85 + } 86 + } 87 + } 88 + 89 + if (!$rows) { 90 + return $this->applyRules($text); 91 + } 92 + 93 + return $this->renderRemarkupTable($rows); 94 + } 95 + 96 + }
+142
src/infrastructure/markup/blockrule/PhutilRemarkupTableBlockRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupTableBlockRule extends PhutilRemarkupBlockRule { 4 + 5 + public function getMatchingLineCount(array $lines, $cursor) { 6 + $num_lines = 0; 7 + 8 + if (preg_match('/^\s*<table>/i', $lines[$cursor])) { 9 + $num_lines++; 10 + $cursor++; 11 + 12 + while (isset($lines[$cursor])) { 13 + $num_lines++; 14 + if (preg_match('@</table>\s*$@i', $lines[$cursor])) { 15 + break; 16 + } 17 + $cursor++; 18 + } 19 + } 20 + 21 + return $num_lines; 22 + } 23 + 24 + public function markupText($text, $children) { 25 + $root = id(new PhutilHTMLParser()) 26 + ->parseDocument($text); 27 + 28 + $nodes = $root->selectChildrenWithTags(array('table')); 29 + 30 + $out = array(); 31 + $seen_table = false; 32 + foreach ($nodes as $node) { 33 + if ($node->isContentNode()) { 34 + $content = $node->getContent(); 35 + 36 + if (!strlen(trim($content))) { 37 + // Ignore whitespace. 38 + continue; 39 + } 40 + 41 + // If we find other content, fail the rule. This can happen if the 42 + // input is two consecutive table tags on one line with some text 43 + // in between them, which we currently forbid. 44 + return $text; 45 + } else { 46 + // If we have multiple table tags, just return the raw text. 47 + if ($seen_table) { 48 + return $text; 49 + } 50 + $seen_table = true; 51 + 52 + $out[] = $this->newTable($node); 53 + } 54 + } 55 + 56 + if ($this->getEngine()->isTextMode()) { 57 + return implode('', $out); 58 + } else { 59 + return phutil_implode_html('', $out); 60 + } 61 + } 62 + 63 + private function newTable(PhutilDOMNode $table) { 64 + $nodes = $table->selectChildrenWithTags( 65 + array( 66 + 'colgroup', 67 + 'tr', 68 + )); 69 + 70 + $colgroup = null; 71 + $rows = array(); 72 + 73 + foreach ($nodes as $node) { 74 + if ($node->isContentNode()) { 75 + $content = $node->getContent(); 76 + 77 + // If this is whitespace, ignore it. 78 + if (!strlen(trim($content))) { 79 + continue; 80 + } 81 + 82 + // If we have nonempty content between the rows, this isn't a valid 83 + // table. We can't really do anything reasonable with this, so just 84 + // fail out and render the raw text. 85 + return $table->newRawString(); 86 + } 87 + 88 + if ($node->getTagName() === 'colgroup') { 89 + // This table has multiple "<colgroup />" tags. Just bail out. 90 + if ($colgroup !== null) { 91 + return $table->newRawString(); 92 + } 93 + 94 + // This table has a "<colgroup />" after a "<tr />". We could parse 95 + // this, but just reject it out of an abundance of caution. 96 + if ($rows) { 97 + return $table->newRawString(); 98 + } 99 + 100 + $colgroup = $node; 101 + continue; 102 + } 103 + 104 + $rows[] = $node; 105 + } 106 + 107 + $row_specs = array(); 108 + 109 + foreach ($rows as $row) { 110 + $cells = $row->selectChildrenWithTags(array('td', 'th')); 111 + 112 + $cell_specs = array(); 113 + foreach ($cells as $cell) { 114 + if ($cell->isContentNode()) { 115 + $content = $node->getContent(); 116 + 117 + if (!strlen(trim($content))) { 118 + continue; 119 + } 120 + 121 + return $table->newRawString(); 122 + } 123 + 124 + $content = $cell->newRawContentString(); 125 + $content = $this->applyRules($content); 126 + 127 + $cell_specs[] = array( 128 + 'type' => $cell->getTagName(), 129 + 'content' => $content, 130 + ); 131 + } 132 + 133 + $row_specs[] = array( 134 + 'type' => 'tr', 135 + 'content' => $cell_specs, 136 + ); 137 + } 138 + 139 + return $this->renderRemarkupTable($row_specs); 140 + } 141 + 142 + }
+17
src/infrastructure/markup/blockrule/PhutilRemarkupTestInterpreterRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupTestInterpreterRule 4 + extends PhutilRemarkupBlockInterpreter { 5 + 6 + public function getInterpreterName() { 7 + return 'phutil_test_block_interpreter'; 8 + } 9 + 10 + public function markupContent($content, array $argv) { 11 + return sprintf( 12 + "Content: (%s)\nArgv: (%s)", 13 + $content, 14 + phutil_build_http_querystring($argv)); 15 + } 16 + 17 + }
+24
src/infrastructure/markup/markuprule/PhutilRemarkupBoldRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupBoldRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 1000.0; 7 + } 8 + 9 + public function apply($text) { 10 + if ($this->getEngine()->isTextMode()) { 11 + return $text; 12 + } 13 + 14 + return $this->replaceHTML( 15 + '@\\*\\*(.+?)\\*\\*@s', 16 + array($this, 'applyCallback'), 17 + $text); 18 + } 19 + 20 + protected function applyCallback(array $matches) { 21 + return hsprintf('<strong>%s</strong>', $matches[1]); 22 + } 23 + 24 + }
+24
src/infrastructure/markup/markuprule/PhutilRemarkupDelRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupDelRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 1000.0; 7 + } 8 + 9 + public function apply($text) { 10 + if ($this->getEngine()->isTextMode()) { 11 + return $text; 12 + } 13 + 14 + return $this->replaceHTML( 15 + '@(?<!~)~~([^\s~].*?~*)~~@s', 16 + array($this, 'applyCallback'), 17 + $text); 18 + } 19 + 20 + protected function applyCallback(array $matches) { 21 + return hsprintf('<del>%s</del>', $matches[1]); 22 + } 23 + 24 + }
+175
src/infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupDocumentLinkRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 150.0; 7 + } 8 + 9 + public function apply($text) { 10 + // Handle mediawiki-style links: [[ href | name ]] 11 + $text = preg_replace_callback( 12 + '@\B\\[\\[([^|\\]]+)(?:\\|([^\\]]+))?\\]\\]\B@U', 13 + array($this, 'markupDocumentLink'), 14 + $text); 15 + 16 + // Handle markdown-style links: [name](href) 17 + $text = preg_replace_callback( 18 + '@'. 19 + '\B'. 20 + '\\[([^\\]]+)\\]'. 21 + '\\('. 22 + '(\s*'. 23 + // See T12343. This is making some kind of effort to implement 24 + // parenthesis balancing rules. It won't get nested parentheses 25 + // right, but should do OK for Wikipedia pages, which seem to be 26 + // the most important use case. 27 + 28 + // Match zero or more non-parenthesis, non-space characters. 29 + '[^\s()]*'. 30 + // Match zero or more sequences of "(...)", where two balanced 31 + // parentheses enclose zero or more normal characters. If we 32 + // match some, optionally match more stuff at the end. 33 + '(?:(?:\\([^ ()]*\\))+[^\s()]*)?'. 34 + '\s*)'. 35 + '\\)'. 36 + '\B'. 37 + '@U', 38 + array($this, 'markupAlternateLink'), 39 + $text); 40 + 41 + return $text; 42 + } 43 + 44 + protected function renderHyperlink($link, $name) { 45 + $engine = $this->getEngine(); 46 + 47 + $is_anchor = false; 48 + if (strncmp($link, '/', 1) == 0) { 49 + $base = $engine->getConfig('uri.base'); 50 + $base = rtrim($base, '/'); 51 + $link = $base.$link; 52 + } else if (strncmp($link, '#', 1) == 0) { 53 + $here = $engine->getConfig('uri.here'); 54 + $link = $here.$link; 55 + 56 + $is_anchor = true; 57 + } 58 + 59 + if ($engine->isTextMode()) { 60 + // If present, strip off "mailto:" or "tel:". 61 + $link = preg_replace('/^(?:mailto|tel):/', '', $link); 62 + 63 + if (!strlen($name)) { 64 + return $link; 65 + } 66 + 67 + return $name.' <'.$link.'>'; 68 + } 69 + 70 + if (!strlen($name)) { 71 + $name = $link; 72 + $name = preg_replace('/^(?:mailto|tel):/', '', $name); 73 + } 74 + 75 + if ($engine->getState('toc')) { 76 + return $name; 77 + } 78 + 79 + $same_window = $engine->getConfig('uri.same-window', false); 80 + if ($same_window) { 81 + $target = null; 82 + } else { 83 + $target = '_blank'; 84 + } 85 + 86 + // For anchors on the same page, always stay here. 87 + if ($is_anchor) { 88 + $target = null; 89 + } 90 + 91 + return phutil_tag( 92 + 'a', 93 + array( 94 + 'href' => $link, 95 + 'class' => 'remarkup-link', 96 + 'target' => $target, 97 + 'rel' => 'noreferrer', 98 + ), 99 + $name); 100 + } 101 + 102 + public function markupAlternateLink(array $matches) { 103 + $uri = trim($matches[2]); 104 + 105 + if (!strlen($uri)) { 106 + return $matches[0]; 107 + } 108 + 109 + // NOTE: We apply some special rules to avoid false positives here. The 110 + // major concern is that we do not want to convert `x[0][1](y)` in a 111 + // discussion about C source code into a link. To this end, we: 112 + // 113 + // - Don't match at word boundaries; 114 + // - require the URI to contain a "/" character or "@" character; and 115 + // - reject URIs which being with a quote character. 116 + 117 + if ($uri[0] == '"' || $uri[0] == "'" || $uri[0] == '`') { 118 + return $matches[0]; 119 + } 120 + 121 + if (strpos($uri, '/') === false && 122 + strpos($uri, '@') === false && 123 + strncmp($uri, 'tel:', 4)) { 124 + return $matches[0]; 125 + } 126 + 127 + return $this->markupDocumentLink( 128 + array( 129 + $matches[0], 130 + $matches[2], 131 + $matches[1], 132 + )); 133 + } 134 + 135 + public function markupDocumentLink(array $matches) { 136 + $uri = trim($matches[1]); 137 + $name = trim(idx($matches, 2)); 138 + 139 + // If whatever is being linked to begins with "/" or "#", or has "://", 140 + // or is "mailto:" or "tel:", treat it as a URI instead of a wiki page. 141 + $is_uri = preg_match('@(^/)|(://)|(^#)|(^(?:mailto|tel):)@', $uri); 142 + 143 + if ($is_uri && strncmp('/', $uri, 1) && strncmp('#', $uri, 1)) { 144 + $protocols = $this->getEngine()->getConfig( 145 + 'uri.allowed-protocols', 146 + array()); 147 + 148 + try { 149 + $protocol = id(new PhutilURI($uri))->getProtocol(); 150 + if (!idx($protocols, $protocol)) { 151 + // Don't treat this as a URI if it's not an allowed protocol. 152 + $is_uri = false; 153 + } 154 + } catch (Exception $ex) { 155 + // We can end up here if we try to parse an ambiguous URI, see 156 + // T12796. 157 + $is_uri = false; 158 + } 159 + } 160 + 161 + // As a special case, skip "[[ / ]]" so that Phriction picks it up as a 162 + // link to the Phriction root. It is more useful to be able to use this 163 + // syntax to link to the root document than the home page of the install. 164 + if ($uri == '/') { 165 + $is_uri = false; 166 + } 167 + 168 + if (!$is_uri) { 169 + return $matches[0]; 170 + } 171 + 172 + return $this->getEngine()->storeText($this->renderHyperlink($uri, $name)); 173 + } 174 + 175 + }
+19
src/infrastructure/markup/markuprule/PhutilRemarkupEscapeRemarkupRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupEscapeRemarkupRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 0; 7 + } 8 + 9 + public function apply($text) { 10 + if (strpos($text, "\1") === false) { 11 + return $text; 12 + } 13 + 14 + $replace = $this->getEngine()->storeText("\1"); 15 + 16 + return str_replace("\1", $replace, $text); 17 + } 18 + 19 + }
+37
src/infrastructure/markup/markuprule/PhutilRemarkupHighlightRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupHighlightRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 1000.0; 7 + } 8 + 9 + public function apply($text) { 10 + if ($this->getEngine()->isTextMode()) { 11 + return $text; 12 + } 13 + 14 + return $this->replaceHTML( 15 + '@!!(.+?)(!{2,})@', 16 + array($this, 'applyCallback'), 17 + $text); 18 + } 19 + 20 + protected function applyCallback(array $matches) { 21 + // Remove the two exclamation points that represent syntax. 22 + $excitement = substr($matches[2], 2); 23 + 24 + // If the internal content consists of ONLY exclamation points, leave it 25 + // untouched so "!!!!!" is five exclamation points instead of one 26 + // highlighted exclamation point. 27 + if (preg_match('/^!+\z/', $matches[1])) { 28 + return $matches[0]; 29 + } 30 + 31 + // $excitement now has two fewer !'s than we started with. 32 + return hsprintf('<span class="remarkup-highlight">%s%s</span>', 33 + $matches[1], $excitement); 34 + 35 + } 36 + 37 + }
+30
src/infrastructure/markup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php
··· 1 + <?php 2 + 3 + abstract class PhutilRemarkupHyperlinkEngineExtension 4 + extends Phobject { 5 + 6 + private $engine; 7 + 8 + final public function getHyperlinkEngineKey() { 9 + return $this->getPhobjectClassConstant('LINKENGINEKEY', 32); 10 + } 11 + 12 + final public static function getAllLinkEngines() { 13 + return id(new PhutilClassMapQuery()) 14 + ->setAncestorClass(__CLASS__) 15 + ->setUniqueMethod('getHyperlinkEngineKey') 16 + ->execute(); 17 + } 18 + 19 + final public function setEngine(PhutilRemarkupEngine $engine) { 20 + $this->engine = $engine; 21 + return $this; 22 + } 23 + 24 + final public function getEngine() { 25 + return $this->engine; 26 + } 27 + 28 + abstract public function processHyperlinks(array $hyperlinks); 29 + 30 + }
+38
src/infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRef.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupHyperlinkRef 4 + extends Phobject { 5 + 6 + private $token; 7 + private $uri; 8 + private $embed; 9 + private $result; 10 + 11 + public function __construct(array $map) { 12 + $this->token = $map['token']; 13 + $this->uri = $map['uri']; 14 + $this->embed = ($map['mode'] === '{'); 15 + } 16 + 17 + public function getToken() { 18 + return $this->token; 19 + } 20 + 21 + public function getURI() { 22 + return $this->uri; 23 + } 24 + 25 + public function isEmbed() { 26 + return $this->embed; 27 + } 28 + 29 + public function setResult($result) { 30 + $this->result = $result; 31 + return $this; 32 + } 33 + 34 + public function getResult() { 35 + return $this->result; 36 + } 37 + 38 + }
+234
src/infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupHyperlinkRule extends PhutilRemarkupRule { 4 + 5 + const KEY_HYPERLINKS = 'hyperlinks'; 6 + 7 + public function getPriority() { 8 + return 400.0; 9 + } 10 + 11 + public function apply($text) { 12 + // Hyperlinks with explicit "<>" around them get linked exactly, without 13 + // the "<>". Angle brackets are basically special and mean "this is a URL 14 + // with weird characters". This is assumed to be reasonable because they 15 + // don't appear in normal text or normal URLs. 16 + $text = preg_replace_callback( 17 + '@<(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+?)>@', 18 + array($this, 'markupHyperlinkAngle'), 19 + $text); 20 + 21 + // We match "{uri}", but do not link it by default. 22 + $text = preg_replace_callback( 23 + '@{(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+?)}@', 24 + array($this, 'markupHyperlinkCurly'), 25 + $text); 26 + 27 + // Anything else we match "ungreedily", which means we'll look for 28 + // stuff that's probably puncutation or otherwise not part of the URL and 29 + // not link it. This lets someone write "QuicK! Go to 30 + // http://www.example.com/!". We also apply some paren balancing rules. 31 + 32 + // NOTE: We're explicitly avoiding capturing stored blocks, so text like 33 + // `http://www.example.com/[[x | y]]` doesn't get aggressively captured. 34 + $text = preg_replace_callback( 35 + '@(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+)@', 36 + array($this, 'markupHyperlinkUngreedy'), 37 + $text); 38 + 39 + return $text; 40 + } 41 + 42 + public function markupHyperlinkAngle(array $matches) { 43 + return $this->markupHyperlink('<', $matches); 44 + } 45 + 46 + public function markupHyperlinkCurly(array $matches) { 47 + return $this->markupHyperlink('{', $matches); 48 + } 49 + 50 + protected function markupHyperlink($mode, array $matches) { 51 + $raw_uri = $matches[1]; 52 + 53 + try { 54 + $uri = new PhutilURI($raw_uri); 55 + } catch (Exception $ex) { 56 + return $matches[0]; 57 + } 58 + 59 + $engine = $this->getEngine(); 60 + 61 + $token = $engine->storeText($raw_uri); 62 + 63 + $list_key = self::KEY_HYPERLINKS; 64 + $link_list = $engine->getTextMetadata($list_key, array()); 65 + 66 + $link_list[] = array( 67 + 'token' => $token, 68 + 'uri' => $raw_uri, 69 + 'mode' => $mode, 70 + ); 71 + 72 + $engine->setTextMetadata($list_key, $link_list); 73 + 74 + return $token; 75 + } 76 + 77 + protected function renderHyperlink($link, $is_embed) { 78 + // If the URI is "{uri}" and no handler picked it up, we just render it 79 + // as plain text. 80 + if ($is_embed) { 81 + return $this->renderRawLink($link, $is_embed); 82 + } 83 + 84 + $engine = $this->getEngine(); 85 + 86 + $same_window = $engine->getConfig('uri.same-window', false); 87 + if ($same_window) { 88 + $target = null; 89 + } else { 90 + $target = '_blank'; 91 + } 92 + 93 + return phutil_tag( 94 + 'a', 95 + array( 96 + 'href' => $link, 97 + 'class' => 'remarkup-link', 98 + 'target' => $target, 99 + 'rel' => 'noreferrer', 100 + ), 101 + $link); 102 + } 103 + 104 + private function renderRawLink($link, $is_embed) { 105 + if ($is_embed) { 106 + return '{'.$link.'}'; 107 + } else { 108 + return $link; 109 + } 110 + } 111 + 112 + protected function markupHyperlinkUngreedy($matches) { 113 + $match = $matches[1]; 114 + $tail = null; 115 + $trailing = null; 116 + if (preg_match('/[;,.:!?]+$/', $match, $trailing)) { 117 + $tail = $trailing[0]; 118 + $match = substr($match, 0, -strlen($tail)); 119 + } 120 + 121 + // If there's a closing paren at the end but no balancing open paren in 122 + // the URL, don't link the close paren. This is an attempt to gracefully 123 + // handle the two common paren cases, Wikipedia links and English language 124 + // parentheticals, e.g.: 125 + // 126 + // http://en.wikipedia.org/wiki/Noun_(disambiguation) 127 + // (see also http://www.example.com) 128 + // 129 + // We could apply a craftier heuristic here which tries to actually balance 130 + // the parens, but this is probably sufficient. 131 + if (preg_match('/\\)$/', $match) && !preg_match('/\\(/', $match)) { 132 + $tail = ')'.$tail; 133 + $match = substr($match, 0, -1); 134 + } 135 + 136 + try { 137 + $uri = new PhutilURI($match); 138 + } catch (Exception $ex) { 139 + return $matches[0]; 140 + } 141 + 142 + $link = $this->markupHyperlink(null, array(null, $match)); 143 + 144 + return hsprintf('%s%s', $link, $tail); 145 + } 146 + 147 + public function didMarkupText() { 148 + $engine = $this->getEngine(); 149 + 150 + $protocols = $engine->getConfig('uri.allowed-protocols', array()); 151 + $is_toc = $engine->getState('toc'); 152 + $is_text = $engine->isTextMode(); 153 + $is_mail = $engine->isHTMLMailMode(); 154 + 155 + $list_key = self::KEY_HYPERLINKS; 156 + $raw_list = $engine->getTextMetadata($list_key, array()); 157 + 158 + $links = array(); 159 + foreach ($raw_list as $key => $link) { 160 + $token = $link['token']; 161 + $raw_uri = $link['uri']; 162 + $mode = $link['mode']; 163 + 164 + $is_embed = ($mode === '{'); 165 + $is_literal = ($mode === '<'); 166 + 167 + // If we're rendering in a "Table of Contents" or a plain text mode, 168 + // we're going to render the raw URI without modifications. 169 + if ($is_toc || $is_text) { 170 + $result = $this->renderRawLink($raw_uri, $is_embed); 171 + $engine->overwriteStoredText($token, $result); 172 + continue; 173 + } 174 + 175 + // If this URI doesn't use a whitelisted protocol, don't link it. This 176 + // is primarily intended to prevent "javascript://" silliness. 177 + $uri = new PhutilURI($raw_uri); 178 + $protocol = $uri->getProtocol(); 179 + $valid_protocol = idx($protocols, $protocol); 180 + if (!$valid_protocol) { 181 + $result = $this->renderRawLink($raw_uri, $is_embed); 182 + $engine->overwriteStoredText($token, $result); 183 + continue; 184 + } 185 + 186 + // If the URI is written as "<uri>", we'll render it literally even if 187 + // some handler would otherwise deal with it. 188 + // If we're rendering for HTML mail, we also render literally. 189 + if ($is_literal || $is_mail) { 190 + $result = $this->renderHyperlink($raw_uri, $is_embed); 191 + $engine->overwriteStoredText($token, $result); 192 + continue; 193 + } 194 + 195 + // Otherwise, this link is a valid resource which extensions are allowed 196 + // to handle. 197 + $links[$key] = $link; 198 + } 199 + 200 + if (!$links) { 201 + return; 202 + } 203 + 204 + foreach ($links as $key => $link) { 205 + $links[$key] = new PhutilRemarkupHyperlinkRef($link); 206 + } 207 + 208 + $extensions = PhutilRemarkupHyperlinkEngineExtension::getAllLinkEngines(); 209 + foreach ($extensions as $extension) { 210 + $extension = id(clone $extension) 211 + ->setEngine($engine) 212 + ->processHyperlinks($links); 213 + 214 + foreach ($links as $key => $link) { 215 + $result = $link->getResult(); 216 + if ($result !== null) { 217 + $engine->overwriteStoredText($link->getToken(), $result); 218 + unset($links[$key]); 219 + } 220 + } 221 + 222 + if (!$links) { 223 + break; 224 + } 225 + } 226 + 227 + // Render any remaining links in a normal way. 228 + foreach ($links as $link) { 229 + $result = $this->renderHyperlink($link->getURI(), $link->isEmbed()); 230 + $engine->overwriteStoredText($link->getToken(), $result); 231 + } 232 + } 233 + 234 + }
+24
src/infrastructure/markup/markuprule/PhutilRemarkupItalicRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupItalicRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 1000.0; 7 + } 8 + 9 + public function apply($text) { 10 + if ($this->getEngine()->isTextMode()) { 11 + return $text; 12 + } 13 + 14 + return $this->replaceHTML( 15 + '@(?<!:)//(.+?)//@s', 16 + array($this, 'applyCallback'), 17 + $text); 18 + } 19 + 20 + protected function applyCallback(array $matches) { 21 + return hsprintf('<em>%s</em>', $matches[1]); 22 + } 23 + 24 + }
+13
src/infrastructure/markup/markuprule/PhutilRemarkupLinebreaksRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupLinebreaksRule extends PhutilRemarkupRule { 4 + 5 + public function apply($text) { 6 + if ($this->getEngine()->isTextMode()) { 7 + return $text; 8 + } 9 + 10 + return phutil_escape_html_newlines($text); 11 + } 12 + 13 + }
+49
src/infrastructure/markup/markuprule/PhutilRemarkupMonospaceRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupMonospaceRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 100.0; 7 + } 8 + 9 + public function apply($text) { 10 + // NOTE: We don't require a trailing non-boundary on the backtick syntax, 11 + // to permit the use case of naming and pluralizing a class, like 12 + // "Load all the `PhutilArray`s and then iterate over them." In theory, the 13 + // required \B on the leading backtick should protect us from most 14 + // collateral damage. 15 + 16 + return preg_replace_callback( 17 + '@##([\s\S]+?)##|\B`(.+?)`@', 18 + array($this, 'markupMonospacedText'), 19 + $text); 20 + } 21 + 22 + protected function markupMonospacedText(array $matches) { 23 + if ($this->getEngine()->isTextMode()) { 24 + $result = $matches[0]; 25 + 26 + } else 27 + if ($this->getEngine()->isHTMLMailMode()) { 28 + $match = isset($matches[2]) ? $matches[2] : $matches[1]; 29 + $result = phutil_tag( 30 + 'tt', 31 + array( 32 + 'style' => 'background: #ebebeb; font-size: 13px;', 33 + ), 34 + $match); 35 + 36 + } else { 37 + $match = isset($matches[2]) ? $matches[2] : $matches[1]; 38 + $result = phutil_tag( 39 + 'tt', 40 + array( 41 + 'class' => 'remarkup-monospaced', 42 + ), 43 + $match); 44 + } 45 + 46 + return $this->getEngine()->storeText($result); 47 + } 48 + 49 + }
+109
src/infrastructure/markup/markuprule/PhutilRemarkupRule.php
··· 1 + <?php 2 + 3 + abstract class PhutilRemarkupRule extends Phobject { 4 + 5 + private $engine; 6 + private $replaceCallback; 7 + 8 + public function setEngine(PhutilRemarkupEngine $engine) { 9 + $this->engine = $engine; 10 + return $this; 11 + } 12 + 13 + public function getEngine() { 14 + return $this->engine; 15 + } 16 + 17 + public function getPriority() { 18 + return 500.0; 19 + } 20 + 21 + abstract public function apply($text); 22 + 23 + public function getPostprocessKey() { 24 + return spl_object_hash($this); 25 + } 26 + 27 + public function didMarkupText() { 28 + return; 29 + } 30 + 31 + protected function replaceHTML($pattern, $callback, $text) { 32 + $this->replaceCallback = $callback; 33 + return phutil_safe_html(preg_replace_callback( 34 + $pattern, 35 + array($this, 'replaceHTMLCallback'), 36 + phutil_escape_html($text))); 37 + } 38 + 39 + private function replaceHTMLCallback(array $match) { 40 + return phutil_escape_html(call_user_func( 41 + $this->replaceCallback, 42 + array_map('phutil_safe_html', $match))); 43 + } 44 + 45 + 46 + /** 47 + * Safely generate a tag. 48 + * 49 + * In Remarkup contexts, it's not safe to use arbitrary text in tag 50 + * attributes: even though it will be escaped, it may contain replacement 51 + * tokens which are then replaced with markup. 52 + * 53 + * This method acts as @{function:phutil_tag}, but checks attributes before 54 + * using them. 55 + * 56 + * @param string Tag name. 57 + * @param dict<string, wild> Tag attributes. 58 + * @param wild Tag content. 59 + * @return PhutilSafeHTML Tag object. 60 + */ 61 + protected function newTag($name, array $attrs, $content = null) { 62 + foreach ($attrs as $key => $attr) { 63 + if ($attr !== null) { 64 + $attrs[$key] = $this->assertFlatText($attr); 65 + } 66 + } 67 + 68 + return phutil_tag($name, $attrs, $content); 69 + } 70 + 71 + /** 72 + * Assert that a text token is flat (it contains no replacement tokens). 73 + * 74 + * Because tokens can be replaced with markup, it is dangerous to use 75 + * arbitrary input text in tag attributes. Normally, rule precedence should 76 + * prevent this. Asserting that text is flat before using it as an attribute 77 + * provides an extra layer of security. 78 + * 79 + * Normally, you can call @{method:newTag} rather than calling this method 80 + * directly. @{method:newTag} will check attributes for you. 81 + * 82 + * @param wild Ostensibly flat text. 83 + * @return string Flat text. 84 + */ 85 + protected function assertFlatText($text) { 86 + $text = (string)hsprintf('%s', phutil_safe_html($text)); 87 + $rich = (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) !== false); 88 + if ($rich) { 89 + throw new Exception( 90 + pht( 91 + 'Remarkup rule precedence is dangerous: rendering text with tokens '. 92 + 'as flat text!')); 93 + } 94 + 95 + return $text; 96 + } 97 + 98 + /** 99 + * Check whether text is flat (contains no replacement tokens) or not. 100 + * 101 + * @param wild Ostensibly flat text. 102 + * @return bool True if the text is flat. 103 + */ 104 + protected function isFlatText($text) { 105 + $text = (string)hsprintf('%s', phutil_safe_html($text)); 106 + return (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) === false); 107 + } 108 + 109 + }
+24
src/infrastructure/markup/markuprule/PhutilRemarkupUnderlineRule.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupUnderlineRule extends PhutilRemarkupRule { 4 + 5 + public function getPriority() { 6 + return 1000.0; 7 + } 8 + 9 + public function apply($text) { 10 + if ($this->getEngine()->isTextMode()) { 11 + return $text; 12 + } 13 + 14 + return $this->replaceHTML( 15 + '@(?<!_|/)__([^\s_/].*?_*)__(?!/|\.\S)@s', 16 + array($this, 'applyCallback'), 17 + $text); 18 + } 19 + 20 + protected function applyCallback(array $matches) { 21 + return hsprintf('<u>%s</u>', $matches[1]); 22 + } 23 + 24 + }
+302
src/infrastructure/markup/remarkup/PhutilRemarkupEngine.php
··· 1 + <?php 2 + 3 + final class PhutilRemarkupEngine extends PhutilMarkupEngine { 4 + 5 + const MODE_DEFAULT = 0; 6 + const MODE_TEXT = 1; 7 + const MODE_HTML_MAIL = 2; 8 + 9 + const MAX_CHILD_DEPTH = 32; 10 + 11 + private $blockRules = array(); 12 + private $config = array(); 13 + private $mode; 14 + private $metadata = array(); 15 + private $states = array(); 16 + private $postprocessRules = array(); 17 + private $storage; 18 + 19 + public function setConfig($key, $value) { 20 + $this->config[$key] = $value; 21 + return $this; 22 + } 23 + 24 + public function getConfig($key, $default = null) { 25 + return idx($this->config, $key, $default); 26 + } 27 + 28 + public function setMode($mode) { 29 + $this->mode = $mode; 30 + return $this; 31 + } 32 + 33 + public function isTextMode() { 34 + return $this->mode & self::MODE_TEXT; 35 + } 36 + 37 + public function isHTMLMailMode() { 38 + return $this->mode & self::MODE_HTML_MAIL; 39 + } 40 + 41 + public function setBlockRules(array $rules) { 42 + assert_instances_of($rules, 'PhutilRemarkupBlockRule'); 43 + 44 + $rules = msortv($rules, 'getPriorityVector'); 45 + 46 + $this->blockRules = $rules; 47 + foreach ($this->blockRules as $rule) { 48 + $rule->setEngine($this); 49 + } 50 + 51 + $post_rules = array(); 52 + foreach ($this->blockRules as $block_rule) { 53 + foreach ($block_rule->getMarkupRules() as $rule) { 54 + $key = $rule->getPostprocessKey(); 55 + if ($key !== null) { 56 + $post_rules[$key] = $rule; 57 + } 58 + } 59 + } 60 + 61 + $this->postprocessRules = $post_rules; 62 + 63 + return $this; 64 + } 65 + 66 + public function getTextMetadata($key, $default = null) { 67 + if (isset($this->metadata[$key])) { 68 + return $this->metadata[$key]; 69 + } 70 + return idx($this->metadata, $key, $default); 71 + } 72 + 73 + public function setTextMetadata($key, $value) { 74 + $this->metadata[$key] = $value; 75 + return $this; 76 + } 77 + 78 + public function storeText($text) { 79 + if ($this->isTextMode()) { 80 + $text = phutil_safe_html($text); 81 + } 82 + return $this->storage->store($text); 83 + } 84 + 85 + public function overwriteStoredText($token, $new_text) { 86 + if ($this->isTextMode()) { 87 + $new_text = phutil_safe_html($new_text); 88 + } 89 + $this->storage->overwrite($token, $new_text); 90 + return $this; 91 + } 92 + 93 + public function markupText($text) { 94 + return $this->postprocessText($this->preprocessText($text)); 95 + } 96 + 97 + public function pushState($state) { 98 + if (empty($this->states[$state])) { 99 + $this->states[$state] = 0; 100 + } 101 + $this->states[$state]++; 102 + return $this; 103 + } 104 + 105 + public function popState($state) { 106 + if (empty($this->states[$state])) { 107 + throw new Exception(pht("State '%s' pushed more than popped!", $state)); 108 + } 109 + $this->states[$state]--; 110 + if (!$this->states[$state]) { 111 + unset($this->states[$state]); 112 + } 113 + return $this; 114 + } 115 + 116 + public function getState($state) { 117 + return !empty($this->states[$state]); 118 + } 119 + 120 + public function preprocessText($text) { 121 + $this->metadata = array(); 122 + $this->storage = new PhutilRemarkupBlockStorage(); 123 + 124 + $blocks = $this->splitTextIntoBlocks($text); 125 + 126 + $output = array(); 127 + foreach ($blocks as $block) { 128 + $output[] = $this->markupBlock($block); 129 + } 130 + $output = $this->flattenOutput($output); 131 + 132 + $map = $this->storage->getMap(); 133 + $this->storage = null; 134 + $metadata = $this->metadata; 135 + 136 + 137 + return array( 138 + 'output' => $output, 139 + 'storage' => $map, 140 + 'metadata' => $metadata, 141 + ); 142 + } 143 + 144 + private function splitTextIntoBlocks($text, $depth = 0) { 145 + // Apply basic block and paragraph normalization to the text. NOTE: We don't 146 + // strip trailing whitespace because it is semantic in some contexts, 147 + // notably inlined diffs that the author intends to show as a code block. 148 + $text = phutil_split_lines($text, true); 149 + $block_rules = $this->blockRules; 150 + $blocks = array(); 151 + $cursor = 0; 152 + $prev_block = array(); 153 + 154 + while (isset($text[$cursor])) { 155 + $starting_cursor = $cursor; 156 + foreach ($block_rules as $block_rule) { 157 + $num_lines = $block_rule->getMatchingLineCount($text, $cursor); 158 + 159 + if ($num_lines) { 160 + if ($blocks) { 161 + $prev_block = last($blocks); 162 + } 163 + 164 + $curr_block = array( 165 + 'start' => $cursor, 166 + 'num_lines' => $num_lines, 167 + 'rule' => $block_rule, 168 + 'is_empty' => self::isEmptyBlock($text, $cursor, $num_lines), 169 + 'children' => array(), 170 + ); 171 + 172 + if ($prev_block 173 + && self::shouldMergeBlocks($text, $prev_block, $curr_block)) { 174 + $blocks[last_key($blocks)]['num_lines'] += $curr_block['num_lines']; 175 + $blocks[last_key($blocks)]['is_empty'] = 176 + $blocks[last_key($blocks)]['is_empty'] && $curr_block['is_empty']; 177 + } else { 178 + $blocks[] = $curr_block; 179 + } 180 + 181 + $cursor += $num_lines; 182 + break; 183 + } 184 + } 185 + 186 + if ($starting_cursor === $cursor) { 187 + throw new Exception(pht('Block in text did not match any block rule.')); 188 + } 189 + } 190 + 191 + foreach ($blocks as $key => $block) { 192 + $lines = array_slice($text, $block['start'], $block['num_lines']); 193 + $blocks[$key]['text'] = implode('', $lines); 194 + } 195 + 196 + // Stop splitting child blocks apart if we get too deep. This arrests 197 + // any blocks which have looping child rules, and stops the stack from 198 + // exploding if someone writes a hilarious comment with 5,000 levels of 199 + // quoted text. 200 + 201 + if ($depth < self::MAX_CHILD_DEPTH) { 202 + foreach ($blocks as $key => $block) { 203 + $rule = $block['rule']; 204 + if (!$rule->supportsChildBlocks()) { 205 + continue; 206 + } 207 + 208 + list($parent_text, $child_text) = $rule->extractChildText( 209 + $block['text']); 210 + $blocks[$key]['text'] = $parent_text; 211 + $blocks[$key]['children'] = $this->splitTextIntoBlocks( 212 + $child_text, 213 + $depth + 1); 214 + } 215 + } 216 + 217 + return $blocks; 218 + } 219 + 220 + private function markupBlock(array $block) { 221 + $children = array(); 222 + foreach ($block['children'] as $child) { 223 + $children[] = $this->markupBlock($child); 224 + } 225 + 226 + if ($children) { 227 + $children = $this->flattenOutput($children); 228 + } else { 229 + $children = null; 230 + } 231 + 232 + return $block['rule']->markupText($block['text'], $children); 233 + } 234 + 235 + private function flattenOutput(array $output) { 236 + if ($this->isTextMode()) { 237 + $output = implode("\n\n", $output)."\n"; 238 + } else { 239 + $output = phutil_implode_html("\n\n", $output); 240 + } 241 + 242 + return $output; 243 + } 244 + 245 + private static function shouldMergeBlocks($text, $prev_block, $curr_block) { 246 + $block_rules = ipull(array($prev_block, $curr_block), 'rule'); 247 + 248 + $default_rule = 'PhutilRemarkupDefaultBlockRule'; 249 + try { 250 + assert_instances_of($block_rules, $default_rule); 251 + 252 + // If the last block was empty keep merging 253 + if ($prev_block['is_empty']) { 254 + return true; 255 + } 256 + 257 + // If this line is blank keep merging 258 + if ($curr_block['is_empty']) { 259 + return true; 260 + } 261 + 262 + // If the current line and the last line have content, keep merging 263 + if (strlen(trim($text[$curr_block['start'] - 1]))) { 264 + if (strlen(trim($text[$curr_block['start']]))) { 265 + return true; 266 + } 267 + } 268 + } catch (Exception $e) {} 269 + 270 + return false; 271 + } 272 + 273 + private static function isEmptyBlock($text, $start, $num_lines) { 274 + for ($cursor = $start; $cursor < $start + $num_lines; $cursor++) { 275 + if (strlen(trim($text[$cursor]))) { 276 + return false; 277 + } 278 + } 279 + return true; 280 + } 281 + 282 + public function postprocessText(array $dict) { 283 + $this->metadata = idx($dict, 'metadata', array()); 284 + 285 + $this->storage = new PhutilRemarkupBlockStorage(); 286 + $this->storage->setMap(idx($dict, 'storage', array())); 287 + 288 + foreach ($this->blockRules as $block_rule) { 289 + $block_rule->postprocess(); 290 + } 291 + 292 + foreach ($this->postprocessRules as $rule) { 293 + $rule->didMarkupText(); 294 + } 295 + 296 + return $this->restoreText(idx($dict, 'output')); 297 + } 298 + 299 + public function restoreText($text) { 300 + return $this->storage->restore($text, $this->isTextMode()); 301 + } 302 + }
+132
src/infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php
··· 1 + <?php 2 + 3 + /** 4 + * Test cases for @{class:PhutilRemarkupEngine}. 5 + */ 6 + final class PhutilRemarkupEngineTestCase extends PhutilTestCase { 7 + 8 + public function testEngine() { 9 + $root = dirname(__FILE__).'/remarkup/'; 10 + foreach (Filesystem::listDirectory($root, $hidden = false) as $file) { 11 + $this->markupText($root.$file); 12 + } 13 + } 14 + 15 + private function markupText($markup_file) { 16 + $contents = Filesystem::readFile($markup_file); 17 + $file = basename($markup_file); 18 + 19 + $parts = explode("\n~~~~~~~~~~\n", $contents); 20 + $this->assertEqual(3, count($parts), $markup_file); 21 + 22 + list($input_remarkup, $expected_output, $expected_text) = $parts; 23 + 24 + $input_remarkup = $this->unescapeTrailingWhitespace($input_remarkup); 25 + $expected_output = $this->unescapeTrailingWhitespace($expected_output); 26 + $expected_text = $this->unescapeTrailingWhitespace($expected_text); 27 + 28 + $engine = $this->buildNewTestEngine(); 29 + 30 + switch ($file) { 31 + case 'raw-escape.txt': 32 + 33 + // NOTE: Here, we want to test PhutilRemarkupEscapeRemarkupRule and 34 + // PhutilRemarkupBlockStorage, which are triggered by "\1". In the 35 + // test, "~" is used as a placeholder for "\1" since it's hard to type 36 + // "\1". 37 + 38 + $input_remarkup = str_replace('~', "\1", $input_remarkup); 39 + $expected_output = str_replace('~', "\1", $expected_output); 40 + $expected_text = str_replace('~', "\1", $expected_text); 41 + break; 42 + case 'toc.txt': 43 + $engine->setConfig('header.generate-toc', true); 44 + break; 45 + case 'link-same-window.txt': 46 + $engine->setConfig('uri.same-window', true); 47 + break; 48 + case 'link-square.txt': 49 + $engine->setConfig('uri.base', 'http://www.example.com/'); 50 + $engine->setConfig('uri.here', 'http://www.example.com/page/'); 51 + break; 52 + } 53 + 54 + $actual_output = (string)$engine->markupText($input_remarkup); 55 + 56 + switch ($file) { 57 + case 'toc.txt': 58 + $table_of_contents = 59 + PhutilRemarkupHeaderBlockRule::renderTableOfContents($engine); 60 + $actual_output = $table_of_contents."\n\n".$actual_output; 61 + break; 62 + } 63 + 64 + $this->assertEqual( 65 + $expected_output, 66 + $actual_output, 67 + pht("Failed to markup HTML in file '%s'.", $file)); 68 + 69 + $engine->setMode(PhutilRemarkupEngine::MODE_TEXT); 70 + $actual_output = (string)$engine->markupText($input_remarkup); 71 + 72 + $this->assertEqual( 73 + $expected_text, 74 + $actual_output, 75 + pht("Failed to markup text in file '%s'.", $file)); 76 + } 77 + 78 + private function buildNewTestEngine() { 79 + $engine = new PhutilRemarkupEngine(); 80 + 81 + $engine->setConfig( 82 + 'uri.allowed-protocols', 83 + array( 84 + 'http' => true, 85 + 'mailto' => true, 86 + 'tel' => true, 87 + )); 88 + 89 + $rules = array(); 90 + $rules[] = new PhutilRemarkupEscapeRemarkupRule(); 91 + $rules[] = new PhutilRemarkupMonospaceRule(); 92 + $rules[] = new PhutilRemarkupDocumentLinkRule(); 93 + $rules[] = new PhutilRemarkupHyperlinkRule(); 94 + $rules[] = new PhutilRemarkupBoldRule(); 95 + $rules[] = new PhutilRemarkupItalicRule(); 96 + $rules[] = new PhutilRemarkupDelRule(); 97 + $rules[] = new PhutilRemarkupUnderlineRule(); 98 + $rules[] = new PhutilRemarkupHighlightRule(); 99 + 100 + $blocks = array(); 101 + $blocks[] = new PhutilRemarkupQuotesBlockRule(); 102 + $blocks[] = new PhutilRemarkupReplyBlockRule(); 103 + $blocks[] = new PhutilRemarkupHeaderBlockRule(); 104 + $blocks[] = new PhutilRemarkupHorizontalRuleBlockRule(); 105 + $blocks[] = new PhutilRemarkupCodeBlockRule(); 106 + $blocks[] = new PhutilRemarkupLiteralBlockRule(); 107 + $blocks[] = new PhutilRemarkupNoteBlockRule(); 108 + $blocks[] = new PhutilRemarkupTableBlockRule(); 109 + $blocks[] = new PhutilRemarkupSimpleTableBlockRule(); 110 + $blocks[] = new PhutilRemarkupDefaultBlockRule(); 111 + $blocks[] = new PhutilRemarkupListBlockRule(); 112 + $blocks[] = new PhutilRemarkupInterpreterBlockRule(); 113 + 114 + foreach ($blocks as $block) { 115 + if (!($block instanceof PhutilRemarkupCodeBlockRule)) { 116 + $block->setMarkupRules($rules); 117 + } 118 + } 119 + 120 + $engine->setBlockRules($blocks); 121 + 122 + return $engine; 123 + } 124 + 125 + 126 + private function unescapeTrailingWhitespace($input) { 127 + // Remove up to one "~" at the end of each line so trailing whitespace may 128 + // be written in tests as " ~". 129 + return preg_replace('/~$/m', '', $input); 130 + } 131 + 132 + }
+7
src/infrastructure/markup/remarkup/__tests__/remarkup/across-newlines.txt
··· 1 + **duck 2 + quack** 3 + ~~~~~~~~~~ 4 + <p><strong>duck 5 + quack</strong></p> 6 + ~~~~~~~~~~ 7 + **duck quack**
+17
src/infrastructure/markup/remarkup/__tests__/remarkup/backticks-whitespace.txt
··· 1 + ```x``` 2 + 3 + ``` 4 + y 5 + ``` 6 + ~~~~~~~~~~ 7 + <div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code">x</pre></div> 8 + 9 + 10 + 11 + <div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code">y</pre></div> 12 + ~~~~~~~~~~ 13 + x 14 + 15 + 16 + 17 + y
+12
src/infrastructure/markup/remarkup/__tests__/remarkup/block-then-list.txt
··· 1 + lang=txt 2 + code block 3 + 4 + - still a code block 5 + ~~~~~~~~~~ 6 + <div class="remarkup-code-block" data-code-lang="txt" data-sigil="remarkup-code-block"><pre class="remarkup-code">code block 7 + 8 + - still a code block</pre></div> 9 + ~~~~~~~~~~ 10 + code block 11 + 12 + - still a code block
+9
src/infrastructure/markup/remarkup/__tests__/remarkup/code-block-whitespace.txt
··· 1 + lang=txt 2 + x 3 + y 4 + ~~~~~~~~~~ 5 + <div class="remarkup-code-block" data-code-lang="txt" data-sigil="remarkup-code-block"><pre class="remarkup-code"> x 6 + y</pre></div> 7 + ~~~~~~~~~~ 8 + x 9 + y
+11
src/infrastructure/markup/remarkup/__tests__/remarkup/del.txt
··· 1 + omg~~ wtf~~~~~ bbq~~~ lol~~~ 2 + ~~deleted text~~~ 3 + ~~This is a great idea~~~ die forever please 4 + ~~~~~~~ 5 + ~~~~~~~~~~ 6 + <p>omg~~ wtf~~~~~ bbq~~~ lol~~~ 7 + <del>deleted text</del> 8 + <del>This is a great idea~</del> die forever please 9 + ~~~~~~</p> 10 + ~~~~~~~~~~ 11 + omg~~ wtf~~~~~ bbq~~~ lol~~ ~~deleted text~~ ~~This is a great idea~~~ die forever please ~~~~~~~
+36
src/infrastructure/markup/remarkup/__tests__/remarkup/diff.txt
··· 1 + here is a diff 2 + 3 + lang=diff 4 + @@ derp derp @@ 5 + x 6 + y 7 + 8 + - x 9 + - y 10 + + z 11 + 12 + derp derp 13 + ~~~~~~~~~~ 14 + <p>here is a diff</p> 15 + 16 + <div class="remarkup-code-block" data-code-lang="diff" data-sigil="remarkup-code-block"><pre class="remarkup-code">@@ derp derp @@ 17 + x 18 + y 19 + 20 + - x 21 + - y 22 + + z</pre></div> 23 + 24 + <p>derp derp</p> 25 + ~~~~~~~~~~ 26 + here is a diff 27 + 28 + @@ derp derp @@ 29 + x 30 + y 31 + 32 + - x 33 + - y 34 + + z 35 + 36 + derp derp
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/disallowed-link.txt
··· 1 + javascript://www.example.com/ 2 + ~~~~~~~~~~ 3 + <p>javascript://www.example.com/</p> 4 + ~~~~~~~~~~ 5 + javascript://www.example.com/
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/entities.txt
··· 1 + < > & " 2 + ~~~~~~~~~~ 3 + <p>&lt; &gt; &amp; &quot;</p> 4 + ~~~~~~~~~~ 5 + < > & "
+11
src/infrastructure/markup/remarkup/__tests__/remarkup/header-skip.txt
··· 1 + #2 is my favorite. 2 + 3 + #project 4 + ~~~~~~~~~~ 5 + <p>#2 is my favorite.</p> 6 + 7 + <p>#project</p> 8 + ~~~~~~~~~~ 9 + #2 is my favorite. 10 + 11 + #project
+57
src/infrastructure/markup/remarkup/__tests__/remarkup/headers.txt
··· 1 + @nolint (UTF8) 2 + 3 + =a= 4 + 5 + blah blah blah 6 + 7 + 8 + = b = 9 + 10 + Markdown-Style Large Header 11 + ==== 12 + 13 + Markdown-Style Small Header 14 + ---- 15 + 16 + === Remarkup-Style Smaller Header 17 + 18 + 19 + = ☃☃☃ UTF8 Header ☃☃☃ = 20 + ~~~~~~~~~~ 21 + <p>@nolint (UTF8)</p> 22 + 23 + <h2 class="remarkup-header">a</h2> 24 + 25 + <p>blah blah blah</p> 26 + 27 + <h2 class="remarkup-header">b</h2> 28 + 29 + <h2 class="remarkup-header">Markdown-Style Large Header</h2> 30 + 31 + <h3 class="remarkup-header">Markdown-Style Small Header</h3> 32 + 33 + <h4 class="remarkup-header">Remarkup-Style Smaller Header</h4> 34 + 35 + <h2 class="remarkup-header">☃☃☃ UTF8 Header ☃☃☃</h2> 36 + ~~~~~~~~~~ 37 + @nolint (UTF8) 38 + 39 + a 40 + = 41 + 42 + blah blah blah 43 + 44 + b 45 + = 46 + 47 + Markdown-Style Large Header 48 + =========================== 49 + 50 + Markdown-Style Small Header 51 + --------------------------- 52 + 53 + Remarkup-Style Smaller Header 54 + ----------------------------- 55 + 56 + ☃☃☃ UTF8 Header ☃☃☃ 57 + ===================
+9
src/infrastructure/markup/remarkup/__tests__/remarkup/highlight.txt
··· 1 + how about we !!highlight!! some !!TEXT!!! 2 + wow this must be **!!very important!!** 3 + omg!!!!! 4 + ~~~~~~~~~~ 5 + <p>how about we <span class="remarkup-highlight">highlight</span> some <span class="remarkup-highlight">TEXT!</span> 6 + wow this must be <strong><span class="remarkup-highlight">very important</span></strong> 7 + omg!!!!!</p> 8 + ~~~~~~~~~~ 9 + how about we !!highlight!! some !!TEXT!!! wow this must be **!!very important!!** omg!!!!!
+41
src/infrastructure/markup/remarkup/__tests__/remarkup/horizonal-rule.txt
··· 1 + ___ 2 + 3 + _____ 4 + 5 + *** 6 + 7 + * * * * * * * 8 + 9 + --- 10 + 11 + - - - - - - - 12 + 13 + --- 14 + ~~~~~~~~~~ 15 + <hr class="remarkup-hr" /> 16 + 17 + <hr class="remarkup-hr" /> 18 + 19 + <hr class="remarkup-hr" /> 20 + 21 + <hr class="remarkup-hr" /> 22 + 23 + <hr class="remarkup-hr" /> 24 + 25 + <hr class="remarkup-hr" /> 26 + 27 + <hr class="remarkup-hr" /> 28 + ~~~~~~~~~~ 29 + ___ 30 + 31 + _____ 32 + 33 + *** 34 + 35 + * * * * * * * 36 + 37 + --- 38 + 39 + - - - - - - - 40 + 41 + ---
+15
src/infrastructure/markup/remarkup/__tests__/remarkup/important.txt
··· 1 + IMPORTANT: interesting **stuff** 2 + 3 + (IMPORTANT) interesting **stuff** 4 + ~~~~~~~~~~ 5 + <div class="remarkup-important"><span class="remarkup-note-word">IMPORTANT:</span> interesting <strong>stuff</strong></div> 6 + 7 + 8 + 9 + <div class="remarkup-important">interesting <strong>stuff</strong></div> 10 + ~~~~~~~~~~ 11 + IMPORTANT: interesting **stuff** 12 + 13 + 14 + 15 + (IMPORTANT) interesting **stuff**
+58
src/infrastructure/markup/remarkup/__tests__/remarkup/interpreter-test.txt
··· 1 + phutil_test_block_interpreter (foo=bar) {{{ 2 + content 3 + }}} 4 + 5 + phutil_test_block_interpreter {{{ content 6 + content }}} 7 + 8 + phutil_test_block_interpreter {{{ content }}} 9 + 10 + phutil_test_block_interpreter(x=y){{{content}}} 11 + 12 + phutil_fake_test_block_interpreter {{{ content }}} 13 + ~~~~~~~~~~ 14 + Content: (content) 15 + Argv: (foo=bar) 16 + 17 + 18 + 19 + Content: ( content 20 + content ) 21 + Argv: () 22 + 23 + 24 + 25 + Content: ( content ) 26 + Argv: () 27 + 28 + 29 + 30 + Content: (content) 31 + Argv: (x=y) 32 + 33 + 34 + 35 + <div class="remarkup-interpreter-error">No interpreter found: phutil_fake_test_block_interpreter</div> 36 + ~~~~~~~~~~ 37 + Content: (content) 38 + Argv: (foo=bar) 39 + 40 + 41 + 42 + Content: ( content 43 + content ) 44 + Argv: () 45 + 46 + 47 + 48 + Content: ( content ) 49 + Argv: () 50 + 51 + 52 + 53 + Content: (content) 54 + Argv: (x=y) 55 + 56 + 57 + 58 + (No interpreter found: phutil_fake_test_block_interpreter)
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/just-backticks.txt
··· 1 + ``` 2 + ~~~~~~~~~~ 3 + <div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code"></pre></div> 4 + ~~~~~~~~~~ 5 +
+6
src/infrastructure/markup/remarkup/__tests__/remarkup/leading-newline.txt
··· 1 + 2 + a 3 + ~~~~~~~~~~ 4 + <p>a</p> 5 + ~~~~~~~~~~ 6 + a
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/link.txt
··· 1 + http://www.example.com/ 2 + ~~~~~~~~~~ 3 + <p><a href="http://www.example.com/" class="remarkup-link" target="_blank" rel="noreferrer">http://www.example.com/</a></p> 4 + ~~~~~~~~~~ 5 + http://www.example.com/
+15
src/infrastructure/markup/remarkup/__tests__/remarkup/list-alternate-style.txt
··· 1 + - a 2 + -- b 3 + --- c 4 + ~~~~~~~~~~ 5 + <ul class="remarkup-list"> 6 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 7 + <li class="remarkup-list-item">b<ul class="remarkup-list"> 8 + <li class="remarkup-list-item">c</li> 9 + </ul></li> 10 + </ul></li> 11 + </ul> 12 + ~~~~~~~~~~ 13 + - a 14 + - b 15 + - c
+138
src/infrastructure/markup/remarkup/__tests__/remarkup/list-blow-stack.txt
··· 1 + - a 2 + - a 3 + - a 4 + - a 5 + - a 6 + - a 7 + - a 8 + - a 9 + - a 10 + - a 11 + - a 12 + - a 13 + - a 14 + - a 15 + - a 16 + - a 17 + - a 18 + - a 19 + - a 20 + - a 21 + - a 22 + - a 23 + - a 24 + - a 25 + - a 26 + - a 27 + - a 28 + - a 29 + - a 30 + - a 31 + - a 32 + - a 33 + - a 34 + - a 35 + - a 36 + - a 37 + - a 38 + - a 39 + 40 + 41 + derp 42 + ~~~~~~~~~~ 43 + <ul class="remarkup-list"> 44 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 45 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 46 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 47 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 48 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 49 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 50 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 51 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 52 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 53 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 54 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 55 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 56 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 57 + <li class="remarkup-list-item">a</li> 58 + <li class="remarkup-list-item">a</li> 59 + <li class="remarkup-list-item">a</li> 60 + <li class="remarkup-list-item">a</li> 61 + <li class="remarkup-list-item">a</li> 62 + <li class="remarkup-list-item">a</li> 63 + <li class="remarkup-list-item">a</li> 64 + <li class="remarkup-list-item">a</li> 65 + <li class="remarkup-list-item">a</li> 66 + <li class="remarkup-list-item">a</li> 67 + <li class="remarkup-list-item">a</li> 68 + <li class="remarkup-list-item">a</li> 69 + <li class="remarkup-list-item">a</li> 70 + <li class="remarkup-list-item">a</li> 71 + <li class="remarkup-list-item">a</li> 72 + <li class="remarkup-list-item">a</li> 73 + <li class="remarkup-list-item">a</li> 74 + <li class="remarkup-list-item">a</li> 75 + <li class="remarkup-list-item">a</li> 76 + <li class="remarkup-list-item">a</li> 77 + <li class="remarkup-list-item">a</li> 78 + <li class="remarkup-list-item">a</li> 79 + <li class="remarkup-list-item">a</li> 80 + <li class="remarkup-list-item">a</li> 81 + <li class="remarkup-list-item">a</li> 82 + </ul></li> 83 + </ul></li> 84 + </ul></li> 85 + </ul></li> 86 + </ul></li> 87 + </ul></li> 88 + </ul></li> 89 + </ul></li> 90 + </ul></li> 91 + </ul></li> 92 + </ul></li> 93 + </ul></li> 94 + </ul></li> 95 + </ul> 96 + 97 + <p>derp</p> 98 + ~~~~~~~~~~ 99 + - a 100 + - a 101 + - a 102 + - a 103 + - a 104 + - a 105 + - a 106 + - a 107 + - a 108 + - a 109 + - a 110 + - a 111 + - a 112 + - a 113 + - a 114 + - a 115 + - a 116 + - a 117 + - a 118 + - a 119 + - a 120 + - a 121 + - a 122 + - a 123 + - a 124 + - a 125 + - a 126 + - a 127 + - a 128 + - a 129 + - a 130 + - a 131 + - a 132 + - a 133 + - a 134 + - a 135 + - a 136 + - a 137 + 138 + derp
+41
src/infrastructure/markup/remarkup/__tests__/remarkup/list-checkboxes.txt
··· 1 + - [] a 2 + - [ ] b 3 + - [X] c 4 + - d 5 + 6 + [ ] A 7 + [X] B 8 + [ ] C 9 + [ ] D 10 + 11 + [1] footnote 12 + 13 + ~~~~~~~~~~ 14 + <ul class="remarkup-list remarkup-list-with-checkmarks"> 15 + <li class="remarkup-list-item remarkup-unchecked-item"><input type="checkbox" disabled="disabled" /> a</li> 16 + <li class="remarkup-list-item remarkup-unchecked-item"><input type="checkbox" disabled="disabled" /> b</li> 17 + <li class="remarkup-list-item remarkup-checked-item"><input type="checkbox" checked="checked" disabled="disabled" /> c</li> 18 + <li class="remarkup-list-item">d</li> 19 + </ul> 20 + 21 + <ul class="remarkup-list remarkup-list-with-checkmarks"> 22 + <li class="remarkup-list-item remarkup-unchecked-item"><input type="checkbox" disabled="disabled" /> A</li> 23 + <li class="remarkup-list-item remarkup-checked-item"><input type="checkbox" checked="checked" disabled="disabled" /> B<ul class="remarkup-list remarkup-list-with-checkmarks"> 24 + <li class="remarkup-list-item remarkup-unchecked-item"><input type="checkbox" disabled="disabled" /> C</li> 25 + <li class="remarkup-list-item remarkup-unchecked-item"><input type="checkbox" disabled="disabled" /> D</li> 26 + </ul></li> 27 + </ul> 28 + 29 + <p>[1] footnote</p> 30 + ~~~~~~~~~~ 31 + [ ] a 32 + [ ] b 33 + [X] c 34 + - d 35 + 36 + [ ] A 37 + [X] B 38 + [ ] C 39 + [ ] D 40 + 41 + [1] footnote
+15
src/infrastructure/markup/remarkup/__tests__/remarkup/list-crazystairs.txt
··· 1 + ## Fruit 2 + - Apple 3 + - Banana 4 + ~~~~~~~~~~ 5 + <ul class="remarkup-list"> 6 + <li class="remarkup-list-item phantom-item"><ol class="remarkup-list"> 7 + <li class="remarkup-list-item">Fruit</li> 8 + </ol></li> 9 + <li class="remarkup-list-item">Apple</li> 10 + <li class="remarkup-list-item">Banana</li> 11 + </ul> 12 + ~~~~~~~~~~ 13 + 1. Fruit 14 + - Apple 15 + - Banana
+19
src/infrastructure/markup/remarkup/__tests__/remarkup/list-first-style-wins.txt
··· 1 + # item 2 + - item 3 + - item 4 + 5 + derp 6 + ~~~~~~~~~~ 7 + <ol class="remarkup-list"> 8 + <li class="remarkup-list-item">item</li> 9 + <li class="remarkup-list-item">item</li> 10 + <li class="remarkup-list-item">item</li> 11 + </ol> 12 + 13 + <p>derp</p> 14 + ~~~~~~~~~~ 15 + 1. item 16 + 2. item 17 + 3. item 18 + 19 + derp
+19
src/infrastructure/markup/remarkup/__tests__/remarkup/list-hash.txt
··· 1 + # item 2 + # item 3 + # item 4 + 5 + derp 6 + ~~~~~~~~~~ 7 + <ol class="remarkup-list"> 8 + <li class="remarkup-list-item">item</li> 9 + <li class="remarkup-list-item">item</li> 10 + <li class="remarkup-list-item">item</li> 11 + </ol> 12 + 13 + <p>derp</p> 14 + ~~~~~~~~~~ 15 + 1. item 16 + 2. item 17 + 3. item 18 + 19 + derp
+7
src/infrastructure/markup/remarkup/__tests__/remarkup/list-header-last.txt
··· 1 + # At the end of a block, this should be a list. 2 + ~~~~~~~~~~ 3 + <ol class="remarkup-list"> 4 + <li class="remarkup-list-item">At the end of a block, this should be a list.</li> 5 + </ol> 6 + ~~~~~~~~~~ 7 + 1. At the end of a block, this should be a list.
+12
src/infrastructure/markup/remarkup/__tests__/remarkup/list-header.txt
··· 1 + ## Small Header 2 + 3 + This should be a small header. 4 + ~~~~~~~~~~ 5 + <h3 class="remarkup-header">Small Header</h3> 6 + 7 + <p>This should be a small header.</p> 8 + ~~~~~~~~~~ 9 + Small Header 10 + ------------ 11 + 12 + This should be a small header.
+15
src/infrastructure/markup/remarkup/__tests__/remarkup/list-mixed-styles.txt
··· 1 + - a 2 + -- b 3 + --- c 4 + ~~~~~~~~~~ 5 + <ul class="remarkup-list"> 6 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 7 + <li class="remarkup-list-item">b<ul class="remarkup-list"> 8 + <li class="remarkup-list-item">c</li> 9 + </ul></li> 10 + </ul></li> 11 + </ul> 12 + ~~~~~~~~~~ 13 + - a 14 + - b 15 + - c
+14
src/infrastructure/markup/remarkup/__tests__/remarkup/list-multi.txt
··· 1 + - a 2 + -- b 3 + -- c 4 + ~~~~~~~~~~ 5 + <ul class="remarkup-list"> 6 + <li class="remarkup-list-item">a<ul class="remarkup-list"> 7 + <li class="remarkup-list-item">b</li> 8 + <li class="remarkup-list-item">c</li> 9 + </ul></li> 10 + </ul> 11 + ~~~~~~~~~~ 12 + - a 13 + - b 14 + - c
+16
src/infrastructure/markup/remarkup/__tests__/remarkup/list-multiline.txt
··· 1 + - a 2 + a 3 + - b 4 + b 5 + ~~~~~~~~~~ 6 + <ul class="remarkup-list"> 7 + <li class="remarkup-list-item">a a</li> 8 + <li class="remarkup-list-item">b</li> 9 + </ul> 10 + 11 + <p>b</p> 12 + ~~~~~~~~~~ 13 + - a a 14 + - b 15 + 16 + b
+30
src/infrastructure/markup/remarkup/__tests__/remarkup/list-nest.txt
··· 1 + - item 2 + - sub 3 + - item 4 + # sub 5 + # sub 6 + - item 7 + 8 + derp 9 + ~~~~~~~~~~ 10 + <ul class="remarkup-list"> 11 + <li class="remarkup-list-item">item<ul class="remarkup-list"> 12 + <li class="remarkup-list-item">sub</li> 13 + </ul></li> 14 + <li class="remarkup-list-item">item<ol class="remarkup-list"> 15 + <li class="remarkup-list-item">sub</li> 16 + <li class="remarkup-list-item">sub</li> 17 + </ol></li> 18 + <li class="remarkup-list-item">item</li> 19 + </ul> 20 + 21 + <p>derp</p> 22 + ~~~~~~~~~~ 23 + - item 24 + - sub 25 + - item 26 + 1. sub 27 + 2. sub 28 + - item 29 + 30 + derp
+27
src/infrastructure/markup/remarkup/__tests__/remarkup/list-paragraphs.txt
··· 1 + - This is a list item 2 + with several paragraphs. 3 + 4 + This is the second paragraph 5 + of the first list item. 6 + - This is the second item 7 + in the list. 8 + - This is a sublist. 9 + - This is the third item in the list. 10 + 11 + ~~~~~~~~~~ 12 + <ul class="remarkup-list"> 13 + <li class="remarkup-list-item">This is a list item with several paragraphs. 14 + <br /><br /> 15 + This is the second paragraph of the first list item.</li> 16 + <li class="remarkup-list-item">This is the second item in the list.<ul class="remarkup-list"> 17 + <li class="remarkup-list-item">This is a sublist.</li> 18 + </ul></li> 19 + <li class="remarkup-list-item">This is the third item in the list.</li> 20 + </ul> 21 + ~~~~~~~~~~ 22 + - This is a list item with several paragraphs. 23 + 24 + This is the second paragraph of the first list item. 25 + - This is the second item in the list. 26 + - This is a sublist. 27 + - This is the third item in the list.
+23
src/infrastructure/markup/remarkup/__tests__/remarkup/list-staircase.txt
··· 1 + - top 2 + - mid 3 + # bot 4 + 5 + derp 6 + ~~~~~~~~~~ 7 + <ol class="remarkup-list"> 8 + <li class="remarkup-list-item phantom-item"><ul class="remarkup-list"> 9 + <li class="remarkup-list-item phantom-item"><ul class="remarkup-list"> 10 + <li class="remarkup-list-item">top</li> 11 + </ul></li> 12 + <li class="remarkup-list-item">mid</li> 13 + </ul></li> 14 + <li class="remarkup-list-item">bot</li> 15 + </ol> 16 + 17 + <p>derp</p> 18 + ~~~~~~~~~~ 19 + - top 20 + - mid 21 + 1. bot 22 + 23 + derp
+19
src/infrastructure/markup/remarkup/__tests__/remarkup/list-star.txt
··· 1 + * item 2 + * item 3 + * item 4 + 5 + derp 6 + ~~~~~~~~~~ 7 + <ul class="remarkup-list"> 8 + <li class="remarkup-list-item">item</li> 9 + <li class="remarkup-list-item">item</li> 10 + <li class="remarkup-list-item">item</li> 11 + </ul> 12 + 13 + <p>derp</p> 14 + ~~~~~~~~~~ 15 + - item 16 + - item 17 + - item 18 + 19 + derp
+15
src/infrastructure/markup/remarkup/__tests__/remarkup/list-then-a-list.txt
··· 1 + 1) one 2 + 3 + - a 4 + ~~~~~~~~~~ 5 + <ol class="remarkup-list"> 6 + <li class="remarkup-list-item">one</li> 7 + </ol> 8 + 9 + <ul class="remarkup-list"> 10 + <li class="remarkup-list-item">a</li> 11 + </ul> 12 + ~~~~~~~~~~ 13 + 1. one 14 + 15 + - a
+17
src/infrastructure/markup/remarkup/__tests__/remarkup/list-vs-codeblock.txt
··· 1 + This should be a list: 2 + 3 + - apple 4 + - banana 5 + 6 + ~~~~~~~~~~ 7 + <p>This should be a list:</p> 8 + 9 + <ul class="remarkup-list"> 10 + <li class="remarkup-list-item">apple</li> 11 + <li class="remarkup-list-item">banana</li> 12 + </ul> 13 + ~~~~~~~~~~ 14 + This should be a list: 15 + 16 + - apple 17 + - banana
+13
src/infrastructure/markup/remarkup/__tests__/remarkup/list.txt
··· 1 + - < > & " 2 + 3 + text block 4 + ~~~~~~~~~~ 5 + <ul class="remarkup-list"> 6 + <li class="remarkup-list-item">&lt; &gt; &amp; &quot;</li> 7 + </ul> 8 + 9 + <p>text block</p> 10 + ~~~~~~~~~~ 11 + - < > & " 12 + 13 + text block
+18
src/infrastructure/markup/remarkup/__tests__/remarkup/monospaced-in-monospaced.txt
··· 1 + query ##SELECT * FROM `table`## 2 + 3 + `SELECT * FROM ##table##` 4 + 5 + `**x**` 6 + 7 + ~~~~~~~~~~ 8 + <p>query <tt class="remarkup-monospaced">SELECT * FROM `table`</tt></p> 9 + 10 + <p><tt class="remarkup-monospaced">SELECT * FROM ##table##</tt></p> 11 + 12 + <p><tt class="remarkup-monospaced">**x**</tt></p> 13 + ~~~~~~~~~~ 14 + query ##SELECT * FROM `table`## 15 + 16 + `SELECT * FROM ##table##` 17 + 18 + `**x**`
+11
src/infrastructure/markup/remarkup/__tests__/remarkup/monospaced-plural.txt
··· 1 + `Zebra`s 2 + 3 + I can`t and I won`t. 4 + ~~~~~~~~~~ 5 + <p><tt class="remarkup-monospaced">Zebra</tt>s</p> 6 + 7 + <p>I can`t and I won`t.</p> 8 + ~~~~~~~~~~ 9 + `Zebra`s 10 + 11 + I can`t and I won`t.
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/monospaced.txt
··· 1 + cmd ##ls --color > /dev/null## 2 + ~~~~~~~~~~ 3 + <p>cmd <tt class="remarkup-monospaced">ls --color &gt; /dev/null</tt></p> 4 + ~~~~~~~~~~ 5 + cmd ##ls --color > /dev/null##
+30
src/infrastructure/markup/remarkup/__tests__/remarkup/newline-then-block.txt
··· 1 + This is a paragraph. 2 + 3 + 4 + lang=txt 5 + First line of code block. 6 + Second line of code block. 7 + 8 + 9 + <table> 10 + <tr> 11 + <td>Cell 1</td> 12 + <td>Cell 2</td> 13 + </tr> 14 + </table> 15 + ~~~~~~~~~~ 16 + <p>This is a paragraph.</p> 17 + 18 + <div class="remarkup-code-block" data-code-lang="txt" data-sigil="remarkup-code-block"><pre class="remarkup-code">First line of code block. 19 + Second line of code block.</pre></div> 20 + 21 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 22 + <tr><td>Cell 1</td><td>Cell 2</td></tr> 23 + </table></div> 24 + ~~~~~~~~~~ 25 + This is a paragraph. 26 + 27 + First line of code block. 28 + Second line of code block. 29 + 30 + | Cell 1 | Cell 2 |
+14
src/infrastructure/markup/remarkup/__tests__/remarkup/note-multiline.txt
··· 1 + NOTE: a 2 + a 3 + 4 + b 5 + ~~~~~~~~~~ 6 + <div class="remarkup-note"><span class="remarkup-note-word">NOTE:</span> a 7 + a</div> 8 + 9 + <p>b</p> 10 + ~~~~~~~~~~ 11 + NOTE: a 12 + a 13 + 14 + b
+15
src/infrastructure/markup/remarkup/__tests__/remarkup/note.txt
··· 1 + NOTE: interesting **stuff** 2 + 3 + (NOTE) interesting **stuff** 4 + ~~~~~~~~~~ 5 + <div class="remarkup-note"><span class="remarkup-note-word">NOTE:</span> interesting <strong>stuff</strong></div> 6 + 7 + 8 + 9 + <div class="remarkup-note">interesting <strong>stuff</strong></div> 10 + ~~~~~~~~~~ 11 + NOTE: interesting **stuff** 12 + 13 + 14 + 15 + (NOTE) interesting **stuff**
+64
src/infrastructure/markup/remarkup/__tests__/remarkup/ordered-list-with-numbers.txt
··· 1 + # aasdx 2 + # asdf 3 + 4 + 1. asa 5 + # asdf 6 + 234) asdf 7 + 8 + 234) asd 9 + 10 + 1. asd 11 + 234) asd 12 + 13 + 10. ten 14 + 11. eleven 15 + 12. twelve 16 + 17 + 1/ This explicitly should not be formatted as a list. 18 + ~~~~~~~~~~ 19 + <ol class="remarkup-list"> 20 + <li class="remarkup-list-item">aasdx</li> 21 + <li class="remarkup-list-item">asdf</li> 22 + </ol> 23 + 24 + <ol class="remarkup-list"> 25 + <li class="remarkup-list-item">asa<ol class="remarkup-list"> 26 + <li class="remarkup-list-item">asdf</li> 27 + </ol></li> 28 + <li class="remarkup-list-item">asdf</li> 29 + </ol> 30 + 31 + <ol class="remarkup-list" start="234"> 32 + <li class="remarkup-list-item">asd</li> 33 + </ol> 34 + 35 + <ol class="remarkup-list"> 36 + <li class="remarkup-list-item">asd</li> 37 + <li class="remarkup-list-item">asd</li> 38 + </ol> 39 + 40 + <ol class="remarkup-list" start="10"> 41 + <li class="remarkup-list-item">ten</li> 42 + <li class="remarkup-list-item">eleven</li> 43 + <li class="remarkup-list-item">twelve</li> 44 + </ol> 45 + 46 + <p>1/ This explicitly should not be formatted as a list.</p> 47 + ~~~~~~~~~~ 48 + 1. aasdx 49 + 2. asdf 50 + 51 + 1. asa 52 + 1. asdf 53 + 2. asdf 54 + 55 + 234. asd 56 + 57 + 1. asd 58 + 2. asd 59 + 60 + 10. ten 61 + 11. eleven 62 + 12. twelve 63 + 64 + 1/ This explicitly should not be formatted as a list.
+29
src/infrastructure/markup/remarkup/__tests__/remarkup/percent-block-adjacent.txt
··· 1 + %%%a%%% 2 + %%%b%%% 3 + 4 + %%%a 5 + b%%% 6 + 7 + %%%a%%% 8 + 9 + %%%b%%% 10 + ~~~~~~~~~~ 11 + <p class="remarkup-literal">a 12 + <br />b</p> 13 + 14 + <p class="remarkup-literal">a 15 + <br />b</p> 16 + 17 + <p class="remarkup-literal">a</p> 18 + 19 + <p class="remarkup-literal">b</p> 20 + ~~~~~~~~~~ 21 + a 22 + b 23 + 24 + a 25 + b 26 + 27 + a 28 + 29 + b
+21
src/infrastructure/markup/remarkup/__tests__/remarkup/percent-block-multiline.txt
··· 1 + **foo** 2 + %%%- first 3 + - second 4 + - third%%% 5 + [[http://hello | world]] 6 + ~~~~~~~~~~ 7 + <p><strong>foo</strong></p> 8 + 9 + <p class="remarkup-literal">- first 10 + <br />- second 11 + <br />- third</p> 12 + 13 + <p><a href="http://hello" class="remarkup-link" target="_blank" rel="noreferrer">world</a></p> 14 + ~~~~~~~~~~ 15 + **foo** 16 + 17 + - first 18 + - second 19 + - third 20 + 21 + world <http://hello>
+11
src/infrastructure/markup/remarkup/__tests__/remarkup/percent-block-oneline.txt
··· 1 + %%%[[http://hello | world]] **bold**%%% 2 + 3 + %%%[[http://hello | world]] **bold**%%% 4 + ~~~~~~~~~~ 5 + <p class="remarkup-literal">[[http://hello | world]] **bold**</p> 6 + 7 + <p class="remarkup-literal">[[http://hello | world]] **bold**</p> 8 + ~~~~~~~~~~ 9 + [[http://hello | world]] **bold** 10 + 11 + [[http://hello | world]] **bold**
+8
src/infrastructure/markup/remarkup/__tests__/remarkup/percent-block-solo.txt
··· 1 + %%% 2 + **x**%%% 3 + ~~~~~~~~~~ 4 + <p class="remarkup-literal"> 5 + <br />**x**</p> 6 + ~~~~~~~~~~ 7 + 8 + **x**
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/quoted-angry.txt
··· 1 + >>> REQUESTING CHANGES BECAUSE I'M ANGRY! 2 + ~~~~~~~~~~ 3 + <blockquote><blockquote><blockquote><p>REQUESTING CHANGES BECAUSE I&#039;M ANGRY!</p></blockquote></blockquote></blockquote> 4 + ~~~~~~~~~~ 5 + >>> REQUESTING CHANGES BECAUSE I'M ANGRY!
+16
src/infrastructure/markup/remarkup/__tests__/remarkup/quoted-code-block.txt
··· 1 + > This should be a code block: 2 + > 3 + > ```lang=php 4 + > <?php 5 + > $foo = 'bar'; 6 + > ``` 7 + ~~~~~~~~~~ 8 + <blockquote><p>This should be a code block:</p> 9 + 10 + <div class="remarkup-code-block" data-code-lang="php" data-sigil="remarkup-code-block"><pre class="remarkup-code"><span class="o">&lt;?php</span> 11 + <span class="nv">$foo</span> <span class="k">=</span> <span class="s">&#039;bar&#039;</span><span class="k">;</span></pre></div></blockquote> 12 + ~~~~~~~~~~ 13 + > This should be a code block: 14 + > 15 + > <?php 16 + > $foo = 'bar';
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/quoted-indent-block.txt
··· 1 + > xyz 2 + ~~~~~~~~~~ 3 + <blockquote><div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code">xyz</pre></div></blockquote> 4 + ~~~~~~~~~~ 5 + > xyz
+24
src/infrastructure/markup/remarkup/__tests__/remarkup/quoted-lists.txt
··· 1 + > # X 2 + > # Y 3 + > 4 + > B 5 + > 6 + > * C 7 + ~~~~~~~~~~ 8 + <blockquote><ol class="remarkup-list"> 9 + <li class="remarkup-list-item">X</li> 10 + <li class="remarkup-list-item">Y</li> 11 + </ol> 12 + 13 + <p>B</p> 14 + 15 + <ul class="remarkup-list"> 16 + <li class="remarkup-list-item">C</li> 17 + </ul></blockquote> 18 + ~~~~~~~~~~ 19 + > 1. X 20 + > 2. Y 21 + > 22 + > B 23 + > 24 + > - C
+19
src/infrastructure/markup/remarkup/__tests__/remarkup/quoted-quote.txt
··· 1 + >>! In U, W wrote: 2 + > - Y 3 + > 4 + > Z 5 + ~~~~~~~~~~ 6 + <blockquote class="remarkup-reply-block"> 7 + <div class="remarkup-reply-head">In U, W wrote:</div> 8 + <div class="remarkup-reply-body"><ul class="remarkup-list"> 9 + <li class="remarkup-list-item">Y</li> 10 + </ul> 11 + 12 + <p>Z</p></div> 13 + </blockquote> 14 + ~~~~~~~~~~ 15 + In U, W wrote: 16 + 17 + > - Y 18 + > 19 + > Z
+9
src/infrastructure/markup/remarkup/__tests__/remarkup/quotes.txt
··· 1 + > Dear Sir, 2 + > I am utterly disgusted with the quality 3 + > of your inflight food service. 4 + ~~~~~~~~~~ 5 + <blockquote><p>Dear Sir, 6 + I am utterly disgusted with the quality 7 + of your inflight food service.</p></blockquote> 8 + ~~~~~~~~~~ 9 + > Dear Sir, I am utterly disgusted with the quality of your inflight food service.
+17
src/infrastructure/markup/remarkup/__tests__/remarkup/raw-escape.txt
··· 1 + ~1~~ 2 + 3 + ~2Z 4 + 5 + ~a 6 + ~~~~~~~~~~ 7 + <p>~1~</p> 8 + 9 + <p>~2Z</p> 10 + 11 + <p>~a</p> 12 + ~~~~~~~~~~ 13 + ~1~~ 14 + 15 + ~2Z 16 + 17 + ~a
+11
src/infrastructure/markup/remarkup/__tests__/remarkup/reply-basic.txt
··· 1 + >>! In comment #123, alincoln wrote: 2 + > Four score and twenty years ago... 3 + ~~~~~~~~~~ 4 + <blockquote class="remarkup-reply-block"> 5 + <div class="remarkup-reply-head">In comment #123, alincoln wrote:</div> 6 + <div class="remarkup-reply-body"><p>Four score and twenty years ago...</p></div> 7 + </blockquote> 8 + ~~~~~~~~~~ 9 + In comment #123, alincoln wrote: 10 + 11 + > Four score and twenty years ago...
+48
src/infrastructure/markup/remarkup/__tests__/remarkup/reply-nested.txt
··· 1 + >>! Previously, fruit: 2 + > 3 + > - Apple 4 + > - Banana 5 + > - Cherry 6 + > 7 + >>>! More previously, vegetables: 8 + >> 9 + >> - Potato 10 + >> - Potato 11 + >> - Potato 12 + > 13 + > The end. 14 + 15 + ~~~~~~~~~~ 16 + <blockquote class="remarkup-reply-block"> 17 + <div class="remarkup-reply-head">Previously, fruit:</div> 18 + <div class="remarkup-reply-body"><ul class="remarkup-list"> 19 + <li class="remarkup-list-item">Apple</li> 20 + <li class="remarkup-list-item">Banana</li> 21 + <li class="remarkup-list-item">Cherry</li> 22 + </ul> 23 + 24 + <blockquote class="remarkup-reply-block"> 25 + <div class="remarkup-reply-head">More previously, vegetables:</div> 26 + <div class="remarkup-reply-body"><ul class="remarkup-list"> 27 + <li class="remarkup-list-item">Potato</li> 28 + <li class="remarkup-list-item">Potato</li> 29 + <li class="remarkup-list-item">Potato</li> 30 + </ul></div> 31 + </blockquote> 32 + 33 + <p>The end.</p></div> 34 + </blockquote> 35 + ~~~~~~~~~~ 36 + Previously, fruit: 37 + 38 + > - Apple 39 + > - Banana 40 + > - Cherry 41 + > 42 + > More previously, vegetables: 43 + > 44 + >> - Potato 45 + >> - Potato 46 + >> - Potato 47 + > 48 + > The end.
+13
src/infrastructure/markup/remarkup/__tests__/remarkup/simple-table-with-empty-row.txt
··· 1 + | Alpaca | 2 + | | 3 + | Zebra | 4 + ~~~~~~~~~~ 5 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 6 + <tr><td>Alpaca</td></tr> 7 + <tr><td></td></tr> 8 + <tr><td>Zebra</td></tr> 9 + </table></div> 10 + ~~~~~~~~~~ 11 + | Alpaca | 12 + | | 13 + | Zebra |
+7
src/infrastructure/markup/remarkup/__tests__/remarkup/simple-table-with-leading-space.txt
··· 1 + |a|b| 2 + ~~~~~~~~~~ 3 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 4 + <tr><td>a</td><td>b</td></tr> 5 + </table></div> 6 + ~~~~~~~~~~ 7 + | a | b |
+7
src/infrastructure/markup/remarkup/__tests__/remarkup/simple-table-with-link.txt
··· 1 + | [[ http://example.com | name ]] | [x] | 2 + ~~~~~~~~~~ 3 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 4 + <tr><td><a href="http://example.com" class="remarkup-link" target="_blank" rel="noreferrer">name</a></td><td>[x]</td></tr> 5 + </table></div> 6 + ~~~~~~~~~~ 7 + | name <http://example.com> | [x] |
+24
src/infrastructure/markup/remarkup/__tests__/remarkup/simple-table.txt
··· 1 + | analyze_resources | original | mobile only | www only | both | 2 + | | -------- | ----------- | -------- | ---- | 3 + | //real// | 31 s | 24 s | 31 s | 31 s 4 + | -------- 5 + | //user// | 49 s | 25 s | 31 s | 49 s 6 + | -------- 7 + | //sys// | 24 s | 12 s | 13 s | 24 s 8 + | ------- 9 + ~~~~~~~~~~ 10 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 11 + <tr><td>analyze_resources</td><th>original</th><th>mobile only</th><th>www only</th><th>both</th></tr> 12 + <tr><th><em>real</em></th><td>31 s</td><td>24 s</td><td>31 s</td><td>31 s</td></tr> 13 + <tr><th><em>user</em></th><td>49 s</td><td>25 s</td><td>31 s</td><td>49 s</td></tr> 14 + <tr><th><em>sys</em></th><td>24 s</td><td>12 s</td><td>13 s</td><td>24 s</td></tr> 15 + </table></div> 16 + ~~~~~~~~~~ 17 + | analyze_resources | original | mobile only | www only | both | 18 + | | -------- | ----------- | -------- | ---- | 19 + | //real// | 31 s | 24 s | 31 s | 31 s | 20 + | ----------------- | | | | | 21 + | //user// | 49 s | 25 s | 31 s | 49 s | 22 + | ----------------- | | | | | 23 + | //sys// | 24 s | 12 s | 13 s | 24 s | 24 + | ----------------- | | | | |
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/simple.txt
··· 1 + hello 2 + ~~~~~~~~~~ 3 + <p>hello</p> 4 + ~~~~~~~~~~ 5 + hello
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/table-with-direct-content.txt
··· 1 + <table>quack</table> 2 + ~~~~~~~~~~ 3 + &lt;table&gt;quack&lt;/table&gt; 4 + ~~~~~~~~~~ 5 + <table>quack</table>
+7
src/infrastructure/markup/remarkup/__tests__/remarkup/table-with-leading-space.txt
··· 1 + <table><tr><td>cell</td></tr></table> 2 + ~~~~~~~~~~ 3 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 4 + <tr><td>cell</td></tr> 5 + </table></div> 6 + ~~~~~~~~~~ 7 + | cell |
+8
src/infrastructure/markup/remarkup/__tests__/remarkup/table-with-long-header.txt
··· 1 + |x| 2 + ||-- 3 + ~~~~~~~~~~ 4 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 5 + <tr><td>x</td></tr> 6 + </table></div> 7 + ~~~~~~~~~~ 8 + | x |
+16
src/infrastructure/markup/remarkup/__tests__/remarkup/table.txt
··· 1 + <table> 2 + <tr><th>Table</th><th>Storage</th></tr> 3 + <tr><td>`differential_diff`</td><td>InnoDB</td></tr> 4 + <tr><td>`edge`</td><td>?</td></tr> 5 + </table> 6 + ~~~~~~~~~~ 7 + <div class="remarkup-table-wrap"><table class="remarkup-table"> 8 + <tr><th>Table</th><th>Storage</th></tr> 9 + <tr><td><tt class="remarkup-monospaced">differential_diff</tt></td><td>InnoDB</td></tr> 10 + <tr><td><tt class="remarkup-monospaced">edge</tt></td><td>?</td></tr> 11 + </table></div> 12 + ~~~~~~~~~~ 13 + | Table | Storage | 14 + | ------------------- | ------- | 15 + | `differential_diff` | InnoDB | 16 + | `edge` | ? |
+18
src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi.txt
··· 1 + ```code 2 + 3 + more code 4 + 5 + more code``` 6 + 7 + ~~~~~~~~~~ 8 + <div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code">code 9 + 10 + more code 11 + 12 + more code</pre></div> 13 + ~~~~~~~~~~ 14 + code 15 + 16 + more code 17 + 18 + more code
+5
src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block.txt
··· 1 + ```code``` 2 + ~~~~~~~~~~ 3 + <div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code">code</pre></div> 4 + ~~~~~~~~~~ 5 + code
+29
src/infrastructure/markup/remarkup/__tests__/remarkup/toc.txt
··· 1 + = [[ http://www.example.com/ | link_name ]] = 2 + 3 + == **bold** == 4 + 5 + = http://www.example.com = 6 + 7 + ~~~~~~~~~~ 8 + <ul> 9 + <li><a href="#http-www-example-com-lin">link_name</a></li> 10 + <ul> 11 + <li><a href="#bold"><strong>bold</strong></a></li> 12 + </ul> 13 + <li><a href="#http-www-example-com">http://www.example.com</a></li> 14 + </ul> 15 + 16 + <h2 class="remarkup-header"><a name="http-www-example-com-lin"></a><a href="http://www.example.com/" class="remarkup-link" target="_blank" rel="noreferrer">link_name</a></h2> 17 + 18 + <h3 class="remarkup-header"><a name="bold"></a><strong>bold</strong></h3> 19 + 20 + <h2 class="remarkup-header"><a name="http-www-example-com"></a><a href="http://www.example.com" class="remarkup-link" target="_blank" rel="noreferrer">http://www.example.com</a></h2> 21 + ~~~~~~~~~~ 22 + [[ http://www.example.com/ | link_name ]] 23 + ========================================= 24 + 25 + **bold** 26 + -------- 27 + 28 + http://www.example.com 29 + ======================
+39
src/infrastructure/markup/remarkup/__tests__/remarkup/trailing-whitespace-codeblock.txt
··· 1 + lang=txt 2 + code block 3 + code block 4 + 5 + 6 + 7 + 8 + code block 9 + 10 + 11 + 12 + 13 + code block 14 + ~~~~~~~~~~ 15 + <div class="remarkup-code-block" data-code-lang="txt" data-sigil="remarkup-code-block"><pre class="remarkup-code">code block 16 + code block 17 + 18 + 19 + 20 + 21 + code block 22 + 23 + 24 + 25 + 26 + code block</pre></div> 27 + ~~~~~~~~~~ 28 + code block 29 + code block 30 + 31 + 32 + 33 + 34 + code block 35 + 36 + 37 + 38 + 39 + code block
+13
src/infrastructure/markup/remarkup/__tests__/remarkup/underline.txt
··· 1 + omg__ wtf_____ bbq___ lol__ 2 + __underlined text__ 3 + __This is a great idea___ die forever please 4 + __ 5 + /__notunderlined__/ and also /__notunderlined__.c 6 + ~~~~~~~~~~ 7 + <p>omg__ wtf_____ bbq___ lol__ 8 + <u>underlined text</u> 9 + <u>This is a great idea_</u> die forever please 10 + __ 11 + /__notunderlined__/ and also /__notunderlined__.c</p> 12 + ~~~~~~~~~~ 13 + omg__ wtf_____ bbq___ lol__ __underlined text__ __This is a great idea___ die forever please __ /__notunderlined__/ and also /__notunderlined__.c
+15
src/infrastructure/markup/remarkup/__tests__/remarkup/warning.txt
··· 1 + WARNING: interesting **stuff** 2 + 3 + (WARNING) interesting **stuff** 4 + ~~~~~~~~~~ 5 + <div class="remarkup-warning"><span class="remarkup-note-word">WARNING:</span> interesting <strong>stuff</strong></div> 6 + 7 + 8 + 9 + <div class="remarkup-warning">interesting <strong>stuff</strong></div> 10 + ~~~~~~~~~~ 11 + WARNING: interesting **stuff** 12 + 13 + 14 + 15 + (WARNING) interesting **stuff**
+305
src/infrastructure/storage/connection/AphrontDatabaseConnection.php
··· 1 + <?php 2 + 3 + /** 4 + * @task xaction Transaction Management 5 + */ 6 + abstract class AphrontDatabaseConnection 7 + extends Phobject 8 + implements PhutilQsprintfInterface { 9 + 10 + private $transactionState; 11 + private $readOnly; 12 + private $queryTimeout; 13 + private $locks = array(); 14 + private $lastActiveEpoch; 15 + private $persistent; 16 + 17 + abstract public function getInsertID(); 18 + abstract public function getAffectedRows(); 19 + abstract public function selectAllResults(); 20 + abstract public function executeQuery(PhutilQueryString $query); 21 + abstract public function executeRawQueries(array $raw_queries); 22 + abstract public function close(); 23 + abstract public function openConnection(); 24 + 25 + public function __destruct() { 26 + // NOTE: This does not actually close persistent connections: PHP maintains 27 + // them in the connection pool. 28 + $this->close(); 29 + } 30 + 31 + final public function setLastActiveEpoch($epoch) { 32 + $this->lastActiveEpoch = $epoch; 33 + return $this; 34 + } 35 + 36 + final public function getLastActiveEpoch() { 37 + return $this->lastActiveEpoch; 38 + } 39 + 40 + final public function setPersistent($persistent) { 41 + $this->persistent = $persistent; 42 + return $this; 43 + } 44 + 45 + final public function getPersistent() { 46 + return $this->persistent; 47 + } 48 + 49 + public function queryData($pattern/* , $arg, $arg, ... */) { 50 + $args = func_get_args(); 51 + array_unshift($args, $this); 52 + return call_user_func_array('queryfx_all', $args); 53 + } 54 + 55 + public function query($pattern/* , $arg, $arg, ... */) { 56 + $args = func_get_args(); 57 + array_unshift($args, $this); 58 + return call_user_func_array('queryfx', $args); 59 + } 60 + 61 + 62 + public function supportsAsyncQueries() { 63 + return false; 64 + } 65 + 66 + public function supportsParallelQueries() { 67 + return false; 68 + } 69 + 70 + public function setReadOnly($read_only) { 71 + $this->readOnly = $read_only; 72 + return $this; 73 + } 74 + 75 + public function getReadOnly() { 76 + return $this->readOnly; 77 + } 78 + 79 + public function setQueryTimeout($query_timeout) { 80 + $this->queryTimeout = $query_timeout; 81 + return $this; 82 + } 83 + 84 + public function getQueryTimeout() { 85 + return $this->queryTimeout; 86 + } 87 + 88 + public function asyncQuery($raw_query) { 89 + throw new Exception(pht('Async queries are not supported.')); 90 + } 91 + 92 + public static function resolveAsyncQueries(array $conns, array $asyncs) { 93 + throw new Exception(pht('Async queries are not supported.')); 94 + } 95 + 96 + /** 97 + * Is this connection idle and safe to close? 98 + * 99 + * A connection is "idle" if it can be safely closed without loss of state. 100 + * Connections inside a transaction or holding locks are not idle, even 101 + * though they may not actively be executing queries. 102 + * 103 + * @return bool True if the connection is idle and can be safely closed. 104 + */ 105 + public function isIdle() { 106 + if ($this->isInsideTransaction()) { 107 + return false; 108 + } 109 + 110 + if ($this->isHoldingAnyLock()) { 111 + return false; 112 + } 113 + 114 + return true; 115 + } 116 + 117 + 118 + /* -( Global Locks )------------------------------------------------------- */ 119 + 120 + 121 + public function rememberLock($lock) { 122 + if (isset($this->locks[$lock])) { 123 + throw new Exception( 124 + pht( 125 + 'Trying to remember lock "%s", but this lock has already been '. 126 + 'remembered.', 127 + $lock)); 128 + } 129 + 130 + $this->locks[$lock] = true; 131 + return $this; 132 + } 133 + 134 + 135 + public function forgetLock($lock) { 136 + if (empty($this->locks[$lock])) { 137 + throw new Exception( 138 + pht( 139 + 'Trying to forget lock "%s", but this connection does not remember '. 140 + 'that lock.', 141 + $lock)); 142 + } 143 + 144 + unset($this->locks[$lock]); 145 + return $this; 146 + } 147 + 148 + 149 + public function forgetAllLocks() { 150 + $this->locks = array(); 151 + return $this; 152 + } 153 + 154 + 155 + public function isHoldingAnyLock() { 156 + return (bool)$this->locks; 157 + } 158 + 159 + 160 + /* -( Transaction Management )--------------------------------------------- */ 161 + 162 + 163 + /** 164 + * Begin a transaction, or set a savepoint if the connection is already 165 + * transactional. 166 + * 167 + * @return this 168 + * @task xaction 169 + */ 170 + public function openTransaction() { 171 + $state = $this->getTransactionState(); 172 + $point = $state->getSavepointName(); 173 + $depth = $state->getDepth(); 174 + 175 + $new_transaction = ($depth == 0); 176 + if ($new_transaction) { 177 + $this->query('START TRANSACTION'); 178 + } else { 179 + $this->query('SAVEPOINT '.$point); 180 + } 181 + 182 + $state->increaseDepth(); 183 + 184 + return $this; 185 + } 186 + 187 + 188 + /** 189 + * Commit a transaction, or stage a savepoint for commit once the entire 190 + * transaction completes if inside a transaction stack. 191 + * 192 + * @return this 193 + * @task xaction 194 + */ 195 + public function saveTransaction() { 196 + $state = $this->getTransactionState(); 197 + $depth = $state->decreaseDepth(); 198 + 199 + if ($depth == 0) { 200 + $this->query('COMMIT'); 201 + } 202 + 203 + return $this; 204 + } 205 + 206 + 207 + /** 208 + * Rollback a transaction, or unstage the last savepoint if inside a 209 + * transaction stack. 210 + * 211 + * @return this 212 + */ 213 + public function killTransaction() { 214 + $state = $this->getTransactionState(); 215 + $depth = $state->decreaseDepth(); 216 + 217 + if ($depth == 0) { 218 + $this->query('ROLLBACK'); 219 + } else { 220 + $this->query('ROLLBACK TO SAVEPOINT '.$state->getSavepointName()); 221 + } 222 + 223 + return $this; 224 + } 225 + 226 + 227 + /** 228 + * Returns true if the connection is transactional. 229 + * 230 + * @return bool True if the connection is currently transactional. 231 + * @task xaction 232 + */ 233 + public function isInsideTransaction() { 234 + $state = $this->getTransactionState(); 235 + return ($state->getDepth() > 0); 236 + } 237 + 238 + 239 + /** 240 + * Get the current @{class:AphrontDatabaseTransactionState} object, or create 241 + * one if none exists. 242 + * 243 + * @return AphrontDatabaseTransactionState Current transaction state. 244 + * @task xaction 245 + */ 246 + protected function getTransactionState() { 247 + if (!$this->transactionState) { 248 + $this->transactionState = new AphrontDatabaseTransactionState(); 249 + } 250 + return $this->transactionState; 251 + } 252 + 253 + 254 + /** 255 + * @task xaction 256 + */ 257 + public function beginReadLocking() { 258 + $this->getTransactionState()->beginReadLocking(); 259 + return $this; 260 + } 261 + 262 + 263 + /** 264 + * @task xaction 265 + */ 266 + public function endReadLocking() { 267 + $this->getTransactionState()->endReadLocking(); 268 + return $this; 269 + } 270 + 271 + 272 + /** 273 + * @task xaction 274 + */ 275 + public function isReadLocking() { 276 + return $this->getTransactionState()->isReadLocking(); 277 + } 278 + 279 + 280 + /** 281 + * @task xaction 282 + */ 283 + public function beginWriteLocking() { 284 + $this->getTransactionState()->beginWriteLocking(); 285 + return $this; 286 + } 287 + 288 + 289 + /** 290 + * @task xaction 291 + */ 292 + public function endWriteLocking() { 293 + $this->getTransactionState()->endWriteLocking(); 294 + return $this; 295 + } 296 + 297 + 298 + /** 299 + * @task xaction 300 + */ 301 + public function isWriteLocking() { 302 + return $this->getTransactionState()->isWriteLocking(); 303 + } 304 + 305 + }
+105
src/infrastructure/storage/connection/AphrontDatabaseTransactionState.php
··· 1 + <?php 2 + 3 + /** 4 + * Represents current transaction state of a connection. 5 + */ 6 + final class AphrontDatabaseTransactionState extends Phobject { 7 + 8 + private $depth = 0; 9 + private $readLockLevel = 0; 10 + private $writeLockLevel = 0; 11 + 12 + public function getDepth() { 13 + return $this->depth; 14 + } 15 + 16 + public function increaseDepth() { 17 + return ++$this->depth; 18 + } 19 + 20 + public function decreaseDepth() { 21 + if ($this->depth == 0) { 22 + throw new Exception( 23 + pht( 24 + 'Too many calls to %s or %s!', 25 + 'saveTransaction()', 26 + 'killTransaction()')); 27 + } 28 + 29 + return --$this->depth; 30 + } 31 + 32 + public function getSavepointName() { 33 + return 'Aphront_Savepoint_'.$this->depth; 34 + } 35 + 36 + public function beginReadLocking() { 37 + $this->readLockLevel++; 38 + return $this; 39 + } 40 + 41 + public function endReadLocking() { 42 + if ($this->readLockLevel == 0) { 43 + throw new Exception( 44 + pht( 45 + 'Too many calls to %s!', 46 + __FUNCTION__.'()')); 47 + } 48 + $this->readLockLevel--; 49 + return $this; 50 + } 51 + 52 + public function isReadLocking() { 53 + return ($this->readLockLevel > 0); 54 + } 55 + 56 + public function beginWriteLocking() { 57 + $this->writeLockLevel++; 58 + return $this; 59 + } 60 + 61 + public function endWriteLocking() { 62 + if ($this->writeLockLevel == 0) { 63 + throw new Exception( 64 + pht( 65 + 'Too many calls to %s!', 66 + __FUNCTION__.'()')); 67 + } 68 + $this->writeLockLevel--; 69 + return $this; 70 + } 71 + 72 + public function isWriteLocking() { 73 + return ($this->writeLockLevel > 0); 74 + } 75 + 76 + public function __destruct() { 77 + if ($this->depth) { 78 + throw new Exception( 79 + pht( 80 + 'Process exited with an open transaction! The transaction '. 81 + 'will be implicitly rolled back. Calls to %s must always be '. 82 + 'paired with a call to %s or %s.', 83 + 'openTransaction()', 84 + 'saveTransaction()', 85 + 'killTransaction()')); 86 + } 87 + if ($this->readLockLevel) { 88 + throw new Exception( 89 + pht( 90 + 'Process exited with an open read lock! Call to %s '. 91 + 'must always be paired with a call to %s.', 92 + 'beginReadLocking()', 93 + 'endReadLocking()')); 94 + } 95 + if ($this->writeLockLevel) { 96 + throw new Exception( 97 + pht( 98 + 'Process exited with an open write lock! Call to %s '. 99 + 'must always be paired with a call to %s.', 100 + 'beginWriteLocking()', 101 + 'endWriteLocking()')); 102 + } 103 + } 104 + 105 + }
+132
src/infrastructure/storage/connection/AphrontIsolatedDatabaseConnection.php
··· 1 + <?php 2 + 3 + final class AphrontIsolatedDatabaseConnection 4 + extends AphrontDatabaseConnection { 5 + 6 + private $configuration; 7 + private static $nextInsertID; 8 + private $insertID; 9 + 10 + private $transcript = array(); 11 + 12 + private $allResults; 13 + private $affectedRows; 14 + 15 + public function __construct(array $configuration) { 16 + $this->configuration = $configuration; 17 + 18 + if (self::$nextInsertID === null) { 19 + // Generate test IDs into a distant ID space to reduce the risk of 20 + // collisions and make them distinctive. 21 + self::$nextInsertID = 55555000000 + mt_rand(0, 1000); 22 + } 23 + } 24 + 25 + public function openConnection() { 26 + return; 27 + } 28 + 29 + public function close() { 30 + return; 31 + } 32 + 33 + public function escapeUTF8String($string) { 34 + return '<S>'; 35 + } 36 + 37 + public function escapeBinaryString($string) { 38 + return '<B>'; 39 + } 40 + 41 + public function escapeColumnName($name) { 42 + return '<C>'; 43 + } 44 + 45 + public function escapeMultilineComment($comment) { 46 + return '<K>'; 47 + } 48 + 49 + public function escapeStringForLikeClause($value) { 50 + return '<L>'; 51 + } 52 + 53 + private function getConfiguration($key, $default = null) { 54 + return idx($this->configuration, $key, $default); 55 + } 56 + 57 + public function getInsertID() { 58 + return $this->insertID; 59 + } 60 + 61 + public function getAffectedRows() { 62 + return $this->affectedRows; 63 + } 64 + 65 + public function selectAllResults() { 66 + return $this->allResults; 67 + } 68 + 69 + public function executeQuery(PhutilQueryString $query) { 70 + 71 + // NOTE: "[\s<>K]*" allows any number of (properly escaped) comments to 72 + // appear prior to the allowed keyword, since this connection escapes 73 + // them as "<K>" (above). 74 + 75 + $display_query = $query->getMaskedString(); 76 + $raw_query = $query->getUnmaskedString(); 77 + 78 + $keywords = array( 79 + 'INSERT', 80 + 'UPDATE', 81 + 'DELETE', 82 + 'START', 83 + 'SAVEPOINT', 84 + 'COMMIT', 85 + 'ROLLBACK', 86 + ); 87 + $preg_keywords = array(); 88 + foreach ($keywords as $key => $word) { 89 + $preg_keywords[] = preg_quote($word, '/'); 90 + } 91 + $preg_keywords = implode('|', $preg_keywords); 92 + 93 + if (!preg_match('/^[\s<>K]*('.$preg_keywords.')\s*/i', $raw_query)) { 94 + throw new AphrontNotSupportedQueryException( 95 + pht( 96 + "Database isolation currently only supports some queries. You are ". 97 + "trying to issue a query which does not begin with an allowed ". 98 + "keyword (%s): '%s'.", 99 + implode(', ', $keywords), 100 + $display_query)); 101 + } 102 + 103 + $this->transcript[] = $display_query; 104 + 105 + // NOTE: This method is intentionally simplified for now, since we're only 106 + // using it to stub out inserts/updates. In the future it will probably need 107 + // to grow more powerful. 108 + 109 + $this->allResults = array(); 110 + 111 + // NOTE: We jitter the insert IDs to keep tests honest; a test should cover 112 + // the relationship between objects, not their exact insertion order. This 113 + // guarantees that IDs are unique but makes it impossible to hard-code tests 114 + // against this specific implementation detail. 115 + self::$nextInsertID += mt_rand(1, 10); 116 + $this->insertID = self::$nextInsertID; 117 + $this->affectedRows = 1; 118 + } 119 + 120 + public function executeRawQueries(array $raw_queries) { 121 + $results = array(); 122 + foreach ($raw_queries as $id => $raw_query) { 123 + $results[$id] = array(); 124 + } 125 + return $results; 126 + } 127 + 128 + public function getQueryTranscript() { 129 + return $this->transcript; 130 + } 131 + 132 + }
+405
src/infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php
··· 1 + <?php 2 + 3 + abstract class AphrontBaseMySQLDatabaseConnection 4 + extends AphrontDatabaseConnection { 5 + 6 + private $configuration; 7 + private $connection; 8 + private $connectionPool = array(); 9 + private $lastResult; 10 + 11 + private $nextError; 12 + 13 + abstract protected function connect(); 14 + abstract protected function rawQuery($raw_query); 15 + abstract protected function rawQueries(array $raw_queries); 16 + abstract protected function fetchAssoc($result); 17 + abstract protected function getErrorCode($connection); 18 + abstract protected function getErrorDescription($connection); 19 + abstract protected function closeConnection(); 20 + abstract protected function freeResult($result); 21 + 22 + public function __construct(array $configuration) { 23 + $this->configuration = $configuration; 24 + } 25 + 26 + public function __clone() { 27 + $this->establishConnection(); 28 + } 29 + 30 + public function openConnection() { 31 + $this->requireConnection(); 32 + } 33 + 34 + public function close() { 35 + if ($this->lastResult) { 36 + $this->lastResult = null; 37 + } 38 + if ($this->connection) { 39 + $this->closeConnection(); 40 + $this->connection = null; 41 + } 42 + } 43 + 44 + public function escapeColumnName($name) { 45 + return '`'.str_replace('`', '``', $name).'`'; 46 + } 47 + 48 + 49 + public function escapeMultilineComment($comment) { 50 + // These can either terminate a comment, confuse the hell out of the parser, 51 + // make MySQL execute the comment as a query, or, in the case of semicolon, 52 + // are quasi-dangerous because the semicolon could turn a broken query into 53 + // a working query plus an ignored query. 54 + 55 + static $map = array( 56 + '--' => '(DOUBLEDASH)', 57 + '*/' => '(STARSLASH)', 58 + '//' => '(SLASHSLASH)', 59 + '#' => '(HASH)', 60 + '!' => '(BANG)', 61 + ';' => '(SEMICOLON)', 62 + ); 63 + 64 + $comment = str_replace( 65 + array_keys($map), 66 + array_values($map), 67 + $comment); 68 + 69 + // For good measure, kill anything else that isn't a nice printable 70 + // character. 71 + $comment = preg_replace('/[^\x20-\x7F]+/', ' ', $comment); 72 + 73 + return '/* '.$comment.' */'; 74 + } 75 + 76 + public function escapeStringForLikeClause($value) { 77 + $value = addcslashes($value, '\%_'); 78 + $value = $this->escapeUTF8String($value); 79 + return $value; 80 + } 81 + 82 + protected function getConfiguration($key, $default = null) { 83 + return idx($this->configuration, $key, $default); 84 + } 85 + 86 + private function establishConnection() { 87 + $host = $this->getConfiguration('host'); 88 + $database = $this->getConfiguration('database'); 89 + 90 + $profiler = PhutilServiceProfiler::getInstance(); 91 + $call_id = $profiler->beginServiceCall( 92 + array( 93 + 'type' => 'connect', 94 + 'host' => $host, 95 + 'database' => $database, 96 + )); 97 + 98 + // If we receive these errors, we'll retry the connection up to the 99 + // retry limit. For other errors, we'll fail immediately. 100 + $retry_codes = array( 101 + // "Connection Timeout" 102 + 2002 => true, 103 + 104 + // "Unable to Connect" 105 + 2003 => true, 106 + ); 107 + 108 + $max_retries = max(1, $this->getConfiguration('retries', 3)); 109 + for ($attempt = 1; $attempt <= $max_retries; $attempt++) { 110 + try { 111 + $conn = $this->connect(); 112 + $profiler->endServiceCall($call_id, array()); 113 + break; 114 + } catch (AphrontQueryException $ex) { 115 + $code = $ex->getCode(); 116 + if (($attempt < $max_retries) && isset($retry_codes[$code])) { 117 + $message = pht( 118 + 'Retrying database connection to "%s" after connection '. 119 + 'failure (attempt %d; "%s"; error #%d): %s', 120 + $host, 121 + $attempt, 122 + get_class($ex), 123 + $code, 124 + $ex->getMessage()); 125 + 126 + phlog($message); 127 + } else { 128 + $profiler->endServiceCall($call_id, array()); 129 + throw $ex; 130 + } 131 + } 132 + } 133 + 134 + $this->connection = $conn; 135 + } 136 + 137 + protected function requireConnection() { 138 + if (!$this->connection) { 139 + if ($this->connectionPool) { 140 + $this->connection = array_pop($this->connectionPool); 141 + } else { 142 + $this->establishConnection(); 143 + } 144 + } 145 + return $this->connection; 146 + } 147 + 148 + protected function beginAsyncConnection() { 149 + $connection = $this->requireConnection(); 150 + $this->connection = null; 151 + return $connection; 152 + } 153 + 154 + protected function endAsyncConnection($connection) { 155 + if ($this->connection) { 156 + $this->connectionPool[] = $this->connection; 157 + } 158 + $this->connection = $connection; 159 + } 160 + 161 + public function selectAllResults() { 162 + $result = array(); 163 + $res = $this->lastResult; 164 + if ($res == null) { 165 + throw new Exception(pht('No query result to fetch from!')); 166 + } 167 + while (($row = $this->fetchAssoc($res))) { 168 + $result[] = $row; 169 + } 170 + return $result; 171 + } 172 + 173 + public function executeQuery(PhutilQueryString $query) { 174 + $display_query = $query->getMaskedString(); 175 + $raw_query = $query->getUnmaskedString(); 176 + 177 + $this->lastResult = null; 178 + $retries = max(1, $this->getConfiguration('retries', 3)); 179 + while ($retries--) { 180 + try { 181 + $this->requireConnection(); 182 + $is_write = $this->checkWrite($raw_query); 183 + 184 + $profiler = PhutilServiceProfiler::getInstance(); 185 + $call_id = $profiler->beginServiceCall( 186 + array( 187 + 'type' => 'query', 188 + 'config' => $this->configuration, 189 + 'query' => $display_query, 190 + 'write' => $is_write, 191 + )); 192 + 193 + $result = $this->rawQuery($raw_query); 194 + 195 + $profiler->endServiceCall($call_id, array()); 196 + 197 + if ($this->nextError) { 198 + $result = null; 199 + } 200 + 201 + if ($result) { 202 + $this->lastResult = $result; 203 + break; 204 + } 205 + 206 + $this->throwQueryException($this->connection); 207 + } catch (AphrontConnectionLostQueryException $ex) { 208 + $can_retry = ($retries > 0); 209 + 210 + if ($this->isInsideTransaction()) { 211 + // Zero out the transaction state to prevent a second exception 212 + // ("program exited with open transaction") from being thrown, since 213 + // we're about to throw a more relevant/useful one instead. 214 + $state = $this->getTransactionState(); 215 + while ($state->getDepth()) { 216 + $state->decreaseDepth(); 217 + } 218 + 219 + $can_retry = false; 220 + } 221 + 222 + if ($this->isHoldingAnyLock()) { 223 + $this->forgetAllLocks(); 224 + $can_retry = false; 225 + } 226 + 227 + $this->close(); 228 + 229 + if (!$can_retry) { 230 + throw $ex; 231 + } 232 + } 233 + } 234 + } 235 + 236 + public function executeRawQueries(array $raw_queries) { 237 + if (!$raw_queries) { 238 + return array(); 239 + } 240 + 241 + $is_write = false; 242 + foreach ($raw_queries as $key => $raw_query) { 243 + $is_write = $is_write || $this->checkWrite($raw_query); 244 + $raw_queries[$key] = rtrim($raw_query, "\r\n\t ;"); 245 + } 246 + 247 + $profiler = PhutilServiceProfiler::getInstance(); 248 + $call_id = $profiler->beginServiceCall( 249 + array( 250 + 'type' => 'multi-query', 251 + 'config' => $this->configuration, 252 + 'queries' => $raw_queries, 253 + 'write' => $is_write, 254 + )); 255 + 256 + $results = $this->rawQueries($raw_queries); 257 + 258 + $profiler->endServiceCall($call_id, array()); 259 + 260 + return $results; 261 + } 262 + 263 + protected function processResult($result) { 264 + if (!$result) { 265 + try { 266 + $this->throwQueryException($this->requireConnection()); 267 + } catch (Exception $ex) { 268 + return $ex; 269 + } 270 + } else if (is_bool($result)) { 271 + return $this->getAffectedRows(); 272 + } 273 + $rows = array(); 274 + while (($row = $this->fetchAssoc($result))) { 275 + $rows[] = $row; 276 + } 277 + $this->freeResult($result); 278 + return $rows; 279 + } 280 + 281 + protected function checkWrite($raw_query) { 282 + // NOTE: The opening "(" allows queries in the form of: 283 + // 284 + // (SELECT ...) UNION (SELECT ...) 285 + $is_write = !preg_match('/^[(]*(SELECT|SHOW|EXPLAIN)\s/', $raw_query); 286 + if ($is_write) { 287 + if ($this->getReadOnly()) { 288 + throw new Exception( 289 + pht( 290 + 'Attempting to issue a write query on a read-only '. 291 + 'connection (to database "%s")!', 292 + $this->getConfiguration('database'))); 293 + } 294 + AphrontWriteGuard::willWrite(); 295 + return true; 296 + } 297 + 298 + return false; 299 + } 300 + 301 + protected function throwQueryException($connection) { 302 + if ($this->nextError) { 303 + $errno = $this->nextError; 304 + $error = pht('Simulated error.'); 305 + $this->nextError = null; 306 + } else { 307 + $errno = $this->getErrorCode($connection); 308 + $error = $this->getErrorDescription($connection); 309 + } 310 + $this->throwQueryCodeException($errno, $error); 311 + } 312 + 313 + private function throwCommonException($errno, $error) { 314 + $message = pht('#%d: %s', $errno, $error); 315 + 316 + switch ($errno) { 317 + case 2013: // Connection Dropped 318 + throw new AphrontConnectionLostQueryException($message); 319 + case 2006: // Gone Away 320 + $more = pht( 321 + 'This error may occur if your configured MySQL "wait_timeout" or '. 322 + '"max_allowed_packet" values are too small. This may also indicate '. 323 + 'that something used the MySQL "KILL <process>" command to kill '. 324 + 'the connection running the query.'); 325 + throw new AphrontConnectionLostQueryException("{$message}\n\n{$more}"); 326 + case 1213: // Deadlock 327 + throw new AphrontDeadlockQueryException($message); 328 + case 1205: // Lock wait timeout exceeded 329 + throw new AphrontLockTimeoutQueryException($message); 330 + case 1062: // Duplicate Key 331 + // NOTE: In some versions of MySQL we get a key name back here, but 332 + // older versions just give us a key index ("key 2") so it's not 333 + // portable to parse the key out of the error and attach it to the 334 + // exception. 335 + throw new AphrontDuplicateKeyQueryException($message); 336 + case 1044: // Access denied to database 337 + case 1142: // Access denied to table 338 + case 1143: // Access denied to column 339 + case 1227: // Access denied (e.g., no SUPER for SHOW SLAVE STATUS). 340 + throw new AphrontAccessDeniedQueryException($message); 341 + case 1045: // Access denied (auth) 342 + throw new AphrontInvalidCredentialsQueryException($message); 343 + case 1146: // No such table 344 + case 1049: // No such database 345 + case 1054: // Unknown column "..." in field list 346 + throw new AphrontSchemaQueryException($message); 347 + } 348 + 349 + // TODO: 1064 is syntax error, and quite terrible in production. 350 + 351 + return null; 352 + } 353 + 354 + protected function throwConnectionException($errno, $error, $user, $host) { 355 + $this->throwCommonException($errno, $error); 356 + 357 + $message = pht( 358 + 'Attempt to connect to %s@%s failed with error #%d: %s.', 359 + $user, 360 + $host, 361 + $errno, 362 + $error); 363 + 364 + throw new AphrontConnectionQueryException($message, $errno); 365 + } 366 + 367 + 368 + protected function throwQueryCodeException($errno, $error) { 369 + $this->throwCommonException($errno, $error); 370 + 371 + $message = pht( 372 + '#%d: %s', 373 + $errno, 374 + $error); 375 + 376 + throw new AphrontQueryException($message, $errno); 377 + } 378 + 379 + /** 380 + * Force the next query to fail with a simulated error. This should be used 381 + * ONLY for unit tests. 382 + */ 383 + public function simulateErrorOnNextQuery($error) { 384 + $this->nextError = $error; 385 + return $this; 386 + } 387 + 388 + /** 389 + * Check inserts for characters outside of the BMP. Even with the strictest 390 + * settings, MySQL will silently truncate data when it encounters these, which 391 + * can lead to data loss and security problems. 392 + */ 393 + protected function validateUTF8String($string) { 394 + if (phutil_is_utf8($string)) { 395 + return; 396 + } 397 + 398 + throw new AphrontCharacterSetQueryException( 399 + pht( 400 + 'Attempting to construct a query using a non-utf8 string when '. 401 + 'utf8 is expected. Use the `%%B` conversion to escape binary '. 402 + 'strings data.')); 403 + } 404 + 405 + }
+233
src/infrastructure/storage/connection/mysql/AphrontMySQLDatabaseConnection.php
··· 1 + <?php 2 + 3 + final class AphrontMySQLDatabaseConnection 4 + extends AphrontBaseMySQLDatabaseConnection { 5 + 6 + public function escapeUTF8String($string) { 7 + $this->validateUTF8String($string); 8 + return $this->escapeBinaryString($string); 9 + } 10 + 11 + public function escapeBinaryString($string) { 12 + return mysql_real_escape_string($string, $this->requireConnection()); 13 + } 14 + 15 + public function getInsertID() { 16 + return mysql_insert_id($this->requireConnection()); 17 + } 18 + 19 + public function getAffectedRows() { 20 + return mysql_affected_rows($this->requireConnection()); 21 + } 22 + 23 + protected function closeConnection() { 24 + mysql_close($this->requireConnection()); 25 + } 26 + 27 + protected function connect() { 28 + if (!function_exists('mysql_connect')) { 29 + // We have to '@' the actual call since it can spew all sorts of silly 30 + // noise, but it will also silence fatals caused by not having MySQL 31 + // installed, which has bitten me on three separate occasions. Make sure 32 + // such failures are explicit and loud. 33 + throw new Exception( 34 + pht( 35 + 'About to call %s, but the PHP MySQL extension is not available!', 36 + 'mysql_connect()')); 37 + } 38 + 39 + $user = $this->getConfiguration('user'); 40 + $host = $this->getConfiguration('host'); 41 + $port = $this->getConfiguration('port'); 42 + 43 + if ($port) { 44 + $host .= ':'.$port; 45 + } 46 + 47 + $database = $this->getConfiguration('database'); 48 + 49 + $pass = $this->getConfiguration('pass'); 50 + if ($pass instanceof PhutilOpaqueEnvelope) { 51 + $pass = $pass->openEnvelope(); 52 + } 53 + 54 + $timeout = $this->getConfiguration('timeout'); 55 + $timeout_ini = 'mysql.connect_timeout'; 56 + if ($timeout) { 57 + $old_timeout = ini_get($timeout_ini); 58 + ini_set($timeout_ini, $timeout); 59 + } 60 + 61 + try { 62 + $conn = @mysql_connect( 63 + $host, 64 + $user, 65 + $pass, 66 + $new_link = true, 67 + $flags = 0); 68 + } catch (Exception $ex) { 69 + if ($timeout) { 70 + ini_set($timeout_ini, $old_timeout); 71 + } 72 + throw $ex; 73 + } 74 + 75 + if ($timeout) { 76 + ini_set($timeout_ini, $old_timeout); 77 + } 78 + 79 + if (!$conn) { 80 + $errno = mysql_errno(); 81 + $error = mysql_error(); 82 + $this->throwConnectionException($errno, $error, $user, $host); 83 + } 84 + 85 + if ($database !== null) { 86 + $ret = @mysql_select_db($database, $conn); 87 + if (!$ret) { 88 + $this->throwQueryException($conn); 89 + } 90 + } 91 + 92 + $ok = @mysql_set_charset('utf8mb4', $conn); 93 + if (!$ok) { 94 + mysql_set_charset('binary', $conn); 95 + } 96 + 97 + return $conn; 98 + } 99 + 100 + protected function rawQuery($raw_query) { 101 + return @mysql_query($raw_query, $this->requireConnection()); 102 + } 103 + 104 + /** 105 + * @phutil-external-symbol function mysql_multi_query 106 + * @phutil-external-symbol function mysql_fetch_result 107 + * @phutil-external-symbol function mysql_more_results 108 + * @phutil-external-symbol function mysql_next_result 109 + */ 110 + protected function rawQueries(array $raw_queries) { 111 + $conn = $this->requireConnection(); 112 + $results = array(); 113 + 114 + if (!function_exists('mysql_multi_query')) { 115 + foreach ($raw_queries as $key => $raw_query) { 116 + $results[$key] = $this->processResult($this->rawQuery($raw_query)); 117 + } 118 + return $results; 119 + } 120 + 121 + if (!mysql_multi_query(implode("\n;\n\n", $raw_queries), $conn)) { 122 + $ex = $this->processResult(false); 123 + return array_fill_keys(array_keys($raw_queries), $ex); 124 + } 125 + 126 + $processed_all = false; 127 + foreach ($raw_queries as $key => $raw_query) { 128 + $results[$key] = $this->processResult(@mysql_fetch_result($conn)); 129 + if (!mysql_more_results($conn)) { 130 + $processed_all = true; 131 + break; 132 + } 133 + mysql_next_result($conn); 134 + } 135 + 136 + if (!$processed_all) { 137 + throw new Exception( 138 + pht('There are some results left in the result set.')); 139 + } 140 + 141 + return $results; 142 + } 143 + 144 + protected function freeResult($result) { 145 + mysql_free_result($result); 146 + } 147 + 148 + public function supportsParallelQueries() { 149 + // fb_parallel_query() doesn't support results with different columns. 150 + return false; 151 + } 152 + 153 + /** 154 + * @phutil-external-symbol function fb_parallel_query 155 + */ 156 + public function executeParallelQueries( 157 + array $queries, 158 + array $conns = array()) { 159 + assert_instances_of($conns, __CLASS__); 160 + 161 + $map = array(); 162 + $is_write = false; 163 + foreach ($queries as $id => $query) { 164 + $is_write = $is_write || $this->checkWrite($query); 165 + $conn = idx($conns, $id, $this); 166 + 167 + $host = $conn->getConfiguration('host'); 168 + $port = 0; 169 + $match = null; 170 + if (preg_match('/(.+):(.+)/', $host, $match)) { 171 + list(, $host, $port) = $match; 172 + } 173 + 174 + $pass = $conn->getConfiguration('pass'); 175 + if ($pass instanceof PhutilOpaqueEnvelope) { 176 + $pass = $pass->openEnvelope(); 177 + } 178 + 179 + $map[$id] = array( 180 + 'sql' => $query, 181 + 'ip' => $host, 182 + 'port' => $port, 183 + 'username' => $conn->getConfiguration('user'), 184 + 'password' => $pass, 185 + 'db' => $conn->getConfiguration('database'), 186 + ); 187 + } 188 + 189 + $profiler = PhutilServiceProfiler::getInstance(); 190 + $call_id = $profiler->beginServiceCall( 191 + array( 192 + 'type' => 'multi-query', 193 + 'queries' => $queries, 194 + 'write' => $is_write, 195 + )); 196 + 197 + $map = fb_parallel_query($map); 198 + 199 + $profiler->endServiceCall($call_id, array()); 200 + 201 + $results = array(); 202 + $pos = 0; 203 + $err_pos = 0; 204 + foreach ($queries as $id => $query) { 205 + $errno = idx(idx($map, 'errno', array()), $err_pos); 206 + $err_pos++; 207 + if ($errno) { 208 + try { 209 + $this->throwQueryCodeException($errno, $map['error'][$id]); 210 + } catch (Exception $ex) { 211 + $results[$id] = $ex; 212 + } 213 + continue; 214 + } 215 + $results[$id] = $map['result'][$pos]; 216 + $pos++; 217 + } 218 + return $results; 219 + } 220 + 221 + protected function fetchAssoc($result) { 222 + return mysql_fetch_assoc($result); 223 + } 224 + 225 + protected function getErrorCode($connection) { 226 + return mysql_errno($connection); 227 + } 228 + 229 + protected function getErrorDescription($connection) { 230 + return mysql_error($connection); 231 + } 232 + 233 + }
+244
src/infrastructure/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php
··· 1 + <?php 2 + 3 + /** 4 + * @phutil-external-symbol class mysqli 5 + */ 6 + final class AphrontMySQLiDatabaseConnection 7 + extends AphrontBaseMySQLDatabaseConnection { 8 + 9 + private $connectionOpen = false; 10 + 11 + public function escapeUTF8String($string) { 12 + $this->validateUTF8String($string); 13 + return $this->escapeBinaryString($string); 14 + } 15 + 16 + public function escapeBinaryString($string) { 17 + return $this->requireConnection()->escape_string($string); 18 + } 19 + 20 + public function getInsertID() { 21 + return $this->requireConnection()->insert_id; 22 + } 23 + 24 + public function getAffectedRows() { 25 + return $this->requireConnection()->affected_rows; 26 + } 27 + 28 + protected function closeConnection() { 29 + if ($this->connectionOpen) { 30 + $this->requireConnection()->close(); 31 + $this->connectionOpen = false; 32 + } 33 + } 34 + 35 + protected function connect() { 36 + if (!class_exists('mysqli', false)) { 37 + throw new Exception(pht( 38 + 'About to call new %s, but the PHP MySQLi extension is not available!', 39 + 'mysqli()')); 40 + } 41 + 42 + $user = $this->getConfiguration('user'); 43 + $host = $this->getConfiguration('host'); 44 + $port = $this->getConfiguration('port'); 45 + $database = $this->getConfiguration('database'); 46 + 47 + $pass = $this->getConfiguration('pass'); 48 + if ($pass instanceof PhutilOpaqueEnvelope) { 49 + $pass = $pass->openEnvelope(); 50 + } 51 + 52 + // If the host is "localhost", the port is ignored and mysqli attempts to 53 + // connect over a socket. 54 + if ($port) { 55 + if ($host === 'localhost' || $host === null) { 56 + $host = '127.0.0.1'; 57 + } 58 + } 59 + 60 + $conn = mysqli_init(); 61 + 62 + $timeout = $this->getConfiguration('timeout'); 63 + if ($timeout) { 64 + $conn->options(MYSQLI_OPT_CONNECT_TIMEOUT, $timeout); 65 + } 66 + 67 + if ($this->getPersistent()) { 68 + $host = 'p:'.$host; 69 + } 70 + 71 + @$conn->real_connect( 72 + $host, 73 + $user, 74 + $pass, 75 + $database, 76 + $port); 77 + 78 + $errno = $conn->connect_errno; 79 + if ($errno) { 80 + $error = $conn->connect_error; 81 + $this->throwConnectionException($errno, $error, $user, $host); 82 + } 83 + 84 + // See T13238. Attempt to prevent "LOAD DATA LOCAL INFILE", which allows a 85 + // malicious server to ask the client for any file. At time of writing, 86 + // this option MUST be set after "real_connect()" on all PHP versions. 87 + $conn->options(MYSQLI_OPT_LOCAL_INFILE, 0); 88 + 89 + $this->connectionOpen = true; 90 + 91 + $ok = @$conn->set_charset('utf8mb4'); 92 + if (!$ok) { 93 + $ok = $conn->set_charset('binary'); 94 + } 95 + 96 + return $conn; 97 + } 98 + 99 + protected function rawQuery($raw_query) { 100 + $conn = $this->requireConnection(); 101 + $time_limit = $this->getQueryTimeout(); 102 + 103 + // If we have a query time limit, run this query synchronously but use 104 + // the async API. This allows us to kill queries which take too long 105 + // without requiring any configuration on the server side. 106 + if ($time_limit && $this->supportsAsyncQueries()) { 107 + $conn->query($raw_query, MYSQLI_ASYNC); 108 + 109 + $read = array($conn); 110 + $error = array($conn); 111 + $reject = array($conn); 112 + 113 + $result = mysqli::poll($read, $error, $reject, $time_limit); 114 + 115 + if ($result === false) { 116 + $this->closeConnection(); 117 + throw new Exception( 118 + pht('Failed to poll mysqli connection!')); 119 + } else if ($result === 0) { 120 + $this->closeConnection(); 121 + throw new AphrontQueryTimeoutQueryException( 122 + pht( 123 + 'Query timed out after %s second(s)!', 124 + new PhutilNumber($time_limit))); 125 + } 126 + 127 + return @$conn->reap_async_query(); 128 + } 129 + 130 + $trap = new PhutilErrorTrap(); 131 + 132 + $result = @$conn->query($raw_query); 133 + 134 + $err = $trap->getErrorsAsString(); 135 + $trap->destroy(); 136 + 137 + // See T13238 and PHI1014. Sometimes, the call to "$conn->query()" may fail 138 + // without setting an error code on the connection. One way to reproduce 139 + // this is to use "LOAD DATA LOCAL INFILE" with "mysqli.allow_local_infile" 140 + // disabled. 141 + 142 + // If we have no result and no error code, raise a synthetic query error 143 + // with whatever error message was raised as a local PHP warning. 144 + 145 + if (!$result) { 146 + $error_code = $this->getErrorCode($conn); 147 + if (!$error_code) { 148 + if (strlen($err)) { 149 + $message = $err; 150 + } else { 151 + $message = pht( 152 + 'Call to "mysqli->query()" failed, but did not set an error '. 153 + 'code or emit an error message.'); 154 + } 155 + $this->throwQueryCodeException(777777, $message); 156 + } 157 + } 158 + 159 + return $result; 160 + } 161 + 162 + protected function rawQueries(array $raw_queries) { 163 + $conn = $this->requireConnection(); 164 + 165 + $have_result = false; 166 + $results = array(); 167 + 168 + foreach ($raw_queries as $key => $raw_query) { 169 + if (!$have_result) { 170 + // End line in front of semicolon to allow single line comments at the 171 + // end of queries. 172 + $have_result = $conn->multi_query(implode("\n;\n\n", $raw_queries)); 173 + } else { 174 + $have_result = $conn->next_result(); 175 + } 176 + 177 + array_shift($raw_queries); 178 + 179 + $result = $conn->store_result(); 180 + if (!$result && !$this->getErrorCode($conn)) { 181 + $result = true; 182 + } 183 + $results[$key] = $this->processResult($result); 184 + } 185 + 186 + if ($conn->more_results()) { 187 + throw new Exception( 188 + pht('There are some results left in the result set.')); 189 + } 190 + 191 + return $results; 192 + } 193 + 194 + protected function freeResult($result) { 195 + $result->free_result(); 196 + } 197 + 198 + protected function fetchAssoc($result) { 199 + return $result->fetch_assoc(); 200 + } 201 + 202 + protected function getErrorCode($connection) { 203 + return $connection->errno; 204 + } 205 + 206 + protected function getErrorDescription($connection) { 207 + return $connection->error; 208 + } 209 + 210 + public function supportsAsyncQueries() { 211 + return defined('MYSQLI_ASYNC'); 212 + } 213 + 214 + public function asyncQuery($raw_query) { 215 + $this->checkWrite($raw_query); 216 + $async = $this->beginAsyncConnection(); 217 + $async->query($raw_query, MYSQLI_ASYNC); 218 + return $async; 219 + } 220 + 221 + public static function resolveAsyncQueries(array $conns, array $asyncs) { 222 + assert_instances_of($conns, __CLASS__); 223 + assert_instances_of($asyncs, 'mysqli'); 224 + 225 + $read = $error = $reject = array(); 226 + foreach ($asyncs as $async) { 227 + $read[] = $error[] = $reject[] = $async; 228 + } 229 + 230 + if (!mysqli::poll($read, $error, $reject, 0)) { 231 + return array(); 232 + } 233 + 234 + $results = array(); 235 + foreach ($read as $async) { 236 + $key = array_search($async, $asyncs, $strict = true); 237 + $conn = $conns[$key]; 238 + $conn->endAsyncConnection($async); 239 + $results[$key] = $conn->processResult($async->reap_async_query()); 240 + } 241 + return $results; 242 + } 243 + 244 + }
+4
src/infrastructure/storage/exception/AphrontAccessDeniedQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontAccessDeniedQueryException 4 + extends AphrontQueryException {}
+3
src/infrastructure/storage/exception/AphrontCharacterSetQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontCharacterSetQueryException extends AphrontQueryException {}
+4
src/infrastructure/storage/exception/AphrontConnectionLostQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontConnectionLostQueryException 4 + extends AphrontRecoverableQueryException {}
+3
src/infrastructure/storage/exception/AphrontConnectionQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontConnectionQueryException extends AphrontQueryException {}
+3
src/infrastructure/storage/exception/AphrontCountQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontCountQueryException extends AphrontQueryException {}
+4
src/infrastructure/storage/exception/AphrontDeadlockQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontDeadlockQueryException 4 + extends AphrontRecoverableQueryException {}
+3
src/infrastructure/storage/exception/AphrontDuplicateKeyQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontDuplicateKeyQueryException extends AphrontQueryException {}
+4
src/infrastructure/storage/exception/AphrontInvalidCredentialsQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontInvalidCredentialsQueryException 4 + extends AphrontQueryException {}
+4
src/infrastructure/storage/exception/AphrontLockTimeoutQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontLockTimeoutQueryException 4 + extends AphrontRecoverableQueryException {}
+3
src/infrastructure/storage/exception/AphrontNotSupportedQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontNotSupportedQueryException extends AphrontQueryException {}
+3
src/infrastructure/storage/exception/AphrontObjectMissingQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontObjectMissingQueryException extends AphrontQueryException {}
+16
src/infrastructure/storage/exception/AphrontParameterQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontParameterQueryException extends AphrontQueryException { 4 + 5 + private $query; 6 + 7 + public function __construct($query, $message) { 8 + parent::__construct(pht('%s Query: %s', $message, $query)); 9 + $this->query = $query; 10 + } 11 + 12 + public function getQuery() { 13 + return $this->query; 14 + } 15 + 16 + }
+6
src/infrastructure/storage/exception/AphrontQueryException.php
··· 1 + <?php 2 + 3 + /** 4 + * @concrete-extensible 5 + */ 6 + class AphrontQueryException extends Exception {}
+4
src/infrastructure/storage/exception/AphrontQueryTimeoutQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontQueryTimeoutQueryException 4 + extends AphrontRecoverableQueryException {}
+3
src/infrastructure/storage/exception/AphrontRecoverableQueryException.php
··· 1 + <?php 2 + 3 + abstract class AphrontRecoverableQueryException extends AphrontQueryException {}
+3
src/infrastructure/storage/exception/AphrontSchemaQueryException.php
··· 1 + <?php 2 + 3 + final class AphrontSchemaQueryException extends AphrontQueryException {}
+129
src/infrastructure/storage/future/QueryFuture.php
··· 1 + <?php 2 + 3 + /** 4 + * This class provides several approaches for querying data from the database: 5 + * 6 + * # Async queries: Used under MySQLi with MySQLnd. 7 + * # Parallel queries: Used under HPHP. 8 + * # Multi queries: Used under MySQLi or HPHP. 9 + * # Single queries: Used under MySQL. 10 + * 11 + * The class automatically decides which approach to use. Usage is like with 12 + * other futures: 13 + * 14 + * $futures = array(); 15 + * $futures[] = new QueryFuture($conn1, 'SELECT 1'); 16 + * $futures[] = new QueryFuture($conn1, 'DELETE FROM table'); 17 + * $futures[] = new QueryFuture($conn2, 'SELECT 2'); 18 + * 19 + * foreach (new FutureIterator($futures) as $future) { 20 + * try { 21 + * $result = $future->resolve(); 22 + * } catch (AphrontQueryException $ex) { 23 + * } 24 + * } 25 + * 26 + * `$result` contains a list of dicts for select queries or number of modified 27 + * rows for modification queries. 28 + */ 29 + final class QueryFuture extends Future { 30 + 31 + private static $futures = array(); 32 + 33 + private $conn; 34 + private $query; 35 + private $id; 36 + private $async; 37 + private $profilerCallID; 38 + 39 + public function __construct( 40 + AphrontDatabaseConnection $conn, 41 + $pattern/* , ... */) { 42 + 43 + $this->conn = $conn; 44 + 45 + $args = func_get_args(); 46 + $args = array_slice($args, 2); 47 + $this->query = vqsprintf($conn, $pattern, $args); 48 + 49 + self::$futures[] = $this; 50 + $this->id = last_key(self::$futures); 51 + } 52 + 53 + public function isReady() { 54 + if ($this->result !== null || $this->exception) { 55 + return true; 56 + } 57 + 58 + if (!$this->conn->supportsAsyncQueries()) { 59 + if ($this->conn->supportsParallelQueries()) { 60 + $queries = array(); 61 + $conns = array(); 62 + foreach (self::$futures as $id => $future) { 63 + $queries[$id] = $future->query; 64 + $conns[$id] = $future->conn; 65 + } 66 + $results = $this->conn->executeParallelQueries($queries, $conns); 67 + $this->processResults($results); 68 + return true; 69 + } 70 + 71 + $conns = array(); 72 + $conn_queries = array(); 73 + foreach (self::$futures as $id => $future) { 74 + $hash = spl_object_hash($future->conn); 75 + $conns[$hash] = $future->conn; 76 + $conn_queries[$hash][$id] = $future->query; 77 + } 78 + foreach ($conn_queries as $hash => $queries) { 79 + $this->processResults($conns[$hash]->executeRawQueries($queries)); 80 + } 81 + return true; 82 + } 83 + 84 + if (!$this->async) { 85 + $profiler = PhutilServiceProfiler::getInstance(); 86 + $this->profilerCallID = $profiler->beginServiceCall( 87 + array( 88 + 'type' => 'query', 89 + 'query' => $this->query, 90 + 'async' => true, 91 + )); 92 + 93 + $this->async = $this->conn->asyncQuery($this->query); 94 + return false; 95 + } 96 + 97 + $conns = array(); 98 + $asyncs = array(); 99 + foreach (self::$futures as $id => $future) { 100 + if ($future->async) { 101 + $conns[$id] = $future->conn; 102 + $asyncs[$id] = $future->async; 103 + } 104 + } 105 + 106 + $this->processResults($this->conn->resolveAsyncQueries($conns, $asyncs)); 107 + 108 + if ($this->result !== null || $this->exception) { 109 + return true; 110 + } 111 + return false; 112 + } 113 + 114 + private function processResults(array $results) { 115 + foreach ($results as $id => $result) { 116 + $future = self::$futures[$id]; 117 + if ($result instanceof Exception) { 118 + $future->exception = $result; 119 + } else { 120 + $future->result = $result; 121 + } 122 + unset(self::$futures[$id]); 123 + if ($future->profilerCallID) { 124 + $profiler = PhutilServiceProfiler::getInstance(); 125 + $profiler->endServiceCall($future->profilerCallID, array()); 126 + } 127 + } 128 + } 129 + }
+23
src/infrastructure/storage/xsprintf/AphrontDatabaseTableRef.php
··· 1 + <?php 2 + 3 + final class AphrontDatabaseTableRef 4 + extends Phobject 5 + implements AphrontDatabaseTableRefInterface { 6 + 7 + private $database; 8 + private $table; 9 + 10 + public function __construct($database, $table) { 11 + $this->database = $database; 12 + $this->table = $table; 13 + } 14 + 15 + public function getAphrontRefDatabaseName() { 16 + return $this->database; 17 + } 18 + 19 + public function getAphrontRefTableName() { 20 + return $this->table; 21 + } 22 + 23 + }
+8
src/infrastructure/storage/xsprintf/AphrontDatabaseTableRefInterface.php
··· 1 + <?php 2 + 3 + interface AphrontDatabaseTableRefInterface { 4 + 5 + public function getAphrontRefDatabaseName(); 6 + public function getAphrontRefTableName(); 7 + 8 + }
+9
src/infrastructure/storage/xsprintf/PhutilQsprintfInterface.php
··· 1 + <?php 2 + 3 + interface PhutilQsprintfInterface { 4 + public function escapeBinaryString($string); 5 + public function escapeUTF8String($string); 6 + public function escapeColumnName($string); 7 + public function escapeMultilineComment($string); 8 + public function escapeStringForLikeClause($string); 9 + }
+57
src/infrastructure/storage/xsprintf/PhutilQueryString.php
··· 1 + <?php 2 + 3 + final class PhutilQueryString extends Phobject { 4 + 5 + private $maskedString; 6 + private $unmaskedString; 7 + 8 + public function __construct(PhutilQsprintfInterface $escaper, array $argv) { 9 + // Immediately render the query into a static scalar value. 10 + 11 + // This makes sure we throw immediately if there are errors in the 12 + // parameters, which is much better than throwing later on. 13 + 14 + // This also makes sure that later mutations to objects passed as 15 + // parameters won't affect the outcome. Consider: 16 + // 17 + // $object->setTableName('X'); 18 + // $query = qsprintf($conn, '%R', $object); 19 + // $object->setTableName('Y'); 20 + // 21 + // We'd like "$query" to reference "X", reflecting the object as it 22 + // existed when it was passed to "qsprintf(...)". It's surprising if the 23 + // modification to the object after "qsprintf(...)" can affect "$query". 24 + 25 + $masked_string = xsprintf( 26 + 'xsprintf_query', 27 + array( 28 + 'escaper' => $escaper, 29 + 'unmasked' => false, 30 + ), 31 + $argv); 32 + 33 + $unmasked_string = xsprintf( 34 + 'xsprintf_query', 35 + array( 36 + 'escaper' => $escaper, 37 + 'unmasked' => true, 38 + ), 39 + $argv); 40 + 41 + $this->maskedString = $masked_string; 42 + $this->unmaskedString = $unmasked_string; 43 + } 44 + 45 + public function __toString() { 46 + return $this->getMaskedString(); 47 + } 48 + 49 + public function getUnmaskedString() { 50 + return $this->unmaskedString; 51 + } 52 + 53 + public function getMaskedString() { 54 + return $this->maskedString; 55 + } 56 + 57 + }
+516
src/infrastructure/storage/xsprintf/qsprintf.php
··· 1 + <?php 2 + 3 + /** 4 + * Format an SQL query. This function behaves like `sprintf`, except that all 5 + * the normal conversions (like "%s") will be properly escaped, and additional 6 + * conversions are supported: 7 + * 8 + * %nd, %ns, %nf, %nB 9 + * "Nullable" versions of %d, %s, %f and %B. Will produce 'NULL' if the 10 + * argument is a strict null. 11 + * 12 + * %=d, %=s, %=f 13 + * "Nullable Test" versions of %d, %s and %f. If you pass a value, you 14 + * get "= 3"; if you pass null, you get "IS NULL". For instance, this 15 + * will work properly if `hatID' is a nullable column and $hat is null. 16 + * 17 + * qsprintf($escaper, 'WHERE hatID %=d', $hat); 18 + * 19 + * %Ld, %Ls, %Lf, %LB 20 + * "List" versions of %d, %s, %f and %B. These are appropriate for use in 21 + * an "IN" clause. For example: 22 + * 23 + * qsprintf($escaper, 'WHERE hatID IN (%Ld)', $list_of_hats); 24 + * 25 + * %B ("Binary String") 26 + * Escapes a string for insertion into a pure binary column, ignoring 27 + * tests for characters outside of the basic multilingual plane. 28 + * 29 + * %C, %LC, %LK ("Column", "Key Column") 30 + * Escapes a column name or a list of column names. The "%LK" variant 31 + * escapes a list of key column specifications which may look like 32 + * "column(32)". 33 + * 34 + * %K ("Comment") 35 + * Escapes a comment. 36 + * 37 + * %Q, %LA, %LO, %LQ, %LJ ("Query Fragment") 38 + * Injects a query fragment from a prior call to qsprintf(). The list 39 + * variants join a list of query fragments with AND, OR, comma, or space. 40 + * 41 + * %Z ("Raw Query") 42 + * Injects a raw, unescaped query fragment. Dangerous! 43 + * 44 + * %R ("Database and Table Reference") 45 + * Behaves like "%T.%T" and prints a full reference to a table including 46 + * the database. Accepts a AphrontDatabaseTableRefInterface. 47 + * 48 + * %P ("Password or Secret") 49 + * Behaves like "%s", but shows "********" when the query is printed in 50 + * logs or traces. Accepts a PhutilOpaqueEnvelope. 51 + * 52 + * %~ ("Substring") 53 + * Escapes a substring query for a LIKE (or NOT LIKE) clause. For example: 54 + * 55 + * // Find all rows with $search as a substring of `name`. 56 + * qsprintf($escaper, 'WHERE name LIKE %~', $search); 57 + * 58 + * See also %> and %<. 59 + * 60 + * %> ("Prefix") 61 + * Escapes a prefix query for a LIKE clause. For example: 62 + * 63 + * // Find all rows where `name` starts with $prefix. 64 + * qsprintf($escaper, 'WHERE name LIKE %>', $prefix); 65 + * 66 + * %< ("Suffix") 67 + * Escapes a suffix query for a LIKE clause. For example: 68 + * 69 + * // Find all rows where `name` ends with $suffix. 70 + * qsprintf($escaper, 'WHERE name LIKE %<', $suffix); 71 + * 72 + * %T ("Table") 73 + * Escapes a table name. In most cases, you should use "%R" instead. 74 + */ 75 + function qsprintf(PhutilQsprintfInterface $escaper, $pattern /* , ... */) { 76 + $args = func_get_args(); 77 + array_shift($args); 78 + return new PhutilQueryString($escaper, $args); 79 + } 80 + 81 + function vqsprintf(PhutilQsprintfInterface $escaper, $pattern, array $argv) { 82 + array_unshift($argv, $pattern); 83 + return new PhutilQueryString($escaper, $argv); 84 + } 85 + 86 + /** 87 + * @{function:xsprintf} callback for encoding SQL queries. See 88 + * @{function:qsprintf}. 89 + */ 90 + function xsprintf_query($userdata, &$pattern, &$pos, &$value, &$length) { 91 + $type = $pattern[$pos]; 92 + 93 + if (is_array($userdata)) { 94 + $escaper = $userdata['escaper']; 95 + $unmasked = $userdata['unmasked']; 96 + } else { 97 + $escaper = $userdata; 98 + $unmasked = false; 99 + } 100 + 101 + $next = (strlen($pattern) > $pos + 1) ? $pattern[$pos + 1] : null; 102 + $nullable = false; 103 + $done = false; 104 + 105 + $prefix = ''; 106 + 107 + if (!($escaper instanceof PhutilQsprintfInterface)) { 108 + throw new InvalidArgumentException(pht('Invalid database escaper.')); 109 + } 110 + 111 + switch ($type) { 112 + case '=': // Nullable test 113 + switch ($next) { 114 + case 'd': 115 + case 'f': 116 + case 's': 117 + $pattern = substr_replace($pattern, '', $pos, 1); 118 + $length = strlen($pattern); 119 + $type = 's'; 120 + if ($value === null) { 121 + $value = 'IS NULL'; 122 + $done = true; 123 + } else { 124 + $prefix = '= '; 125 + $type = $next; 126 + } 127 + break; 128 + default: 129 + throw new Exception( 130 + pht( 131 + 'Unknown conversion, try %s, %s, or %s.', 132 + '%=d', 133 + '%=s', 134 + '%=f')); 135 + } 136 + break; 137 + 138 + case 'n': // Nullable... 139 + switch ($next) { 140 + case 'd': // ...integer. 141 + case 'f': // ...float. 142 + case 's': // ...string. 143 + case 'B': // ...binary string. 144 + $pattern = substr_replace($pattern, '', $pos, 1); 145 + $length = strlen($pattern); 146 + $type = $next; 147 + $nullable = true; 148 + break; 149 + default: 150 + throw new XsprintfUnknownConversionException("%n{$next}"); 151 + } 152 + break; 153 + 154 + case 'L': // List of.. 155 + qsprintf_check_type($value, "L{$next}", $pattern); 156 + $pattern = substr_replace($pattern, '', $pos, 1); 157 + $length = strlen($pattern); 158 + $type = 's'; 159 + $done = true; 160 + 161 + switch ($next) { 162 + case 'd': // ...integers. 163 + $value = implode(', ', array_map('intval', $value)); 164 + break; 165 + case 'f': // ...floats. 166 + $value = implode(', ', array_map('floatval', $value)); 167 + break; 168 + case 's': // ...strings. 169 + foreach ($value as $k => $v) { 170 + $value[$k] = "'".$escaper->escapeUTF8String((string)$v)."'"; 171 + } 172 + $value = implode(', ', $value); 173 + break; 174 + case 'B': // ...binary strings. 175 + foreach ($value as $k => $v) { 176 + $value[$k] = "'".$escaper->escapeBinaryString((string)$v)."'"; 177 + } 178 + $value = implode(', ', $value); 179 + break; 180 + case 'C': // ...columns. 181 + foreach ($value as $k => $v) { 182 + $value[$k] = $escaper->escapeColumnName($v); 183 + } 184 + $value = implode(', ', $value); 185 + break; 186 + case 'K': // ...key columns. 187 + // This is like "%LC", but for escaping column lists passed to key 188 + // specifications. These should be escaped as "`column`(123)". For 189 + // example: 190 + // 191 + // ALTER TABLE `x` ADD KEY `y` (`u`(16), `v`(32)); 192 + 193 + foreach ($value as $k => $v) { 194 + $matches = null; 195 + if (preg_match('/\((\d+)\)\z/', $v, $matches)) { 196 + $v = substr($v, 0, -(strlen($matches[1]) + 2)); 197 + $prefix_len = '('.((int)$matches[1]).')'; 198 + } else { 199 + $prefix_len = ''; 200 + } 201 + 202 + $value[$k] = $escaper->escapeColumnName($v).$prefix_len; 203 + } 204 + 205 + $value = implode(', ', $value); 206 + break; 207 + case 'Q': 208 + // TODO: Here, and in "%LO", "%LA", and "%LJ", we should eventually 209 + // stop accepting strings. 210 + foreach ($value as $k => $v) { 211 + if (is_string($v)) { 212 + continue; 213 + } 214 + $value[$k] = $v->getUnmaskedString(); 215 + } 216 + $value = implode(', ', $value); 217 + break; 218 + case 'O': 219 + foreach ($value as $k => $v) { 220 + if (is_string($v)) { 221 + continue; 222 + } 223 + $value[$k] = $v->getUnmaskedString(); 224 + } 225 + if (count($value) == 1) { 226 + $value = '('.head($value).')'; 227 + } else { 228 + $value = '(('.implode(') OR (', $value).'))'; 229 + } 230 + break; 231 + case 'A': 232 + foreach ($value as $k => $v) { 233 + if (is_string($v)) { 234 + continue; 235 + } 236 + $value[$k] = $v->getUnmaskedString(); 237 + } 238 + if (count($value) == 1) { 239 + $value = '('.head($value).')'; 240 + } else { 241 + $value = '(('.implode(') AND (', $value).'))'; 242 + } 243 + break; 244 + case 'J': 245 + foreach ($value as $k => $v) { 246 + if (is_string($v)) { 247 + continue; 248 + } 249 + $value[$k] = $v->getUnmaskedString(); 250 + } 251 + $value = implode(' ', $value); 252 + break; 253 + default: 254 + throw new XsprintfUnknownConversionException("%L{$next}"); 255 + } 256 + break; 257 + } 258 + 259 + if (!$done) { 260 + qsprintf_check_type($value, $type, $pattern); 261 + switch ($type) { 262 + case 's': // String 263 + if ($nullable && $value === null) { 264 + $value = 'NULL'; 265 + } else { 266 + $value = "'".$escaper->escapeUTF8String((string)$value)."'"; 267 + } 268 + $type = 's'; 269 + break; 270 + 271 + case 'B': // Binary String 272 + if ($nullable && $value === null) { 273 + $value = 'NULL'; 274 + } else { 275 + $value = "'".$escaper->escapeBinaryString((string)$value)."'"; 276 + } 277 + $type = 's'; 278 + break; 279 + 280 + case 'Q': // Query Fragment 281 + if ($value instanceof PhutilQueryString) { 282 + $value = $value->getUnmaskedString(); 283 + } 284 + $type = 's'; 285 + break; 286 + 287 + case 'Z': // Raw Query Fragment 288 + $type = 's'; 289 + break; 290 + 291 + case '~': // Like Substring 292 + case '>': // Like Prefix 293 + case '<': // Like Suffix 294 + $value = $escaper->escapeStringForLikeClause($value); 295 + switch ($type) { 296 + case '~': $value = "'%".$value."%'"; break; 297 + case '>': $value = "'".$value."%'"; break; 298 + case '<': $value = "'%".$value."'"; break; 299 + } 300 + $type = 's'; 301 + break; 302 + 303 + case 'f': // Float 304 + if ($nullable && $value === null) { 305 + $value = 'NULL'; 306 + } else { 307 + $value = (float)$value; 308 + } 309 + $type = 's'; 310 + break; 311 + 312 + case 'd': // Integer 313 + if ($nullable && $value === null) { 314 + $value = 'NULL'; 315 + } else { 316 + $value = (int)$value; 317 + } 318 + $type = 's'; 319 + break; 320 + 321 + case 'T': // Table 322 + case 'C': // Column 323 + $value = $escaper->escapeColumnName($value); 324 + $type = 's'; 325 + break; 326 + 327 + case 'K': // Komment 328 + $value = $escaper->escapeMultilineComment($value); 329 + $type = 's'; 330 + break; 331 + 332 + case 'R': // Database + Table Reference 333 + $database_name = $value->getAphrontRefDatabaseName(); 334 + $database_name = $escaper->escapeColumnName($database_name); 335 + 336 + $table_name = $value->getAphrontRefTableName(); 337 + $table_name = $escaper->escapeColumnName($table_name); 338 + 339 + $value = $database_name.'.'.$table_name; 340 + $type = 's'; 341 + break; 342 + 343 + case 'P': // Password or Secret 344 + if ($unmasked) { 345 + $value = $value->openEnvelope(); 346 + $value = "'".$escaper->escapeUTF8String($value)."'"; 347 + } else { 348 + $value = '********'; 349 + } 350 + $type = 's'; 351 + break; 352 + 353 + default: 354 + throw new XsprintfUnknownConversionException($type); 355 + } 356 + } 357 + 358 + if ($prefix) { 359 + $value = $prefix.$value; 360 + } 361 + 362 + $pattern[$pos] = $type; 363 + } 364 + 365 + function qsprintf_check_type($value, $type, $query) { 366 + switch ($type) { 367 + case 'Ld': 368 + case 'Ls': 369 + case 'LC': 370 + case 'LK': 371 + case 'LB': 372 + case 'Lf': 373 + case 'LQ': 374 + case 'LA': 375 + case 'LO': 376 + case 'LJ': 377 + if (!is_array($value)) { 378 + throw new AphrontParameterQueryException( 379 + $query, 380 + pht('Expected array argument for %%%s conversion.', $type)); 381 + } 382 + if (empty($value)) { 383 + throw new AphrontParameterQueryException( 384 + $query, 385 + pht('Array for %%%s conversion is empty.', $type)); 386 + } 387 + 388 + foreach ($value as $scalar) { 389 + qsprintf_check_scalar_type($scalar, $type, $query); 390 + } 391 + break; 392 + default: 393 + qsprintf_check_scalar_type($value, $type, $query); 394 + break; 395 + } 396 + } 397 + 398 + function qsprintf_check_scalar_type($value, $type, $query) { 399 + switch ($type) { 400 + case 'LQ': 401 + case 'LA': 402 + case 'LO': 403 + case 'LJ': 404 + // TODO: See T13217. Remove this eventually. 405 + if (is_string($value)) { 406 + phlog( 407 + pht( 408 + 'UNSAFE: Raw string ("%s") passed to query ("%s") subclause '. 409 + 'for "%%%s" conversion. Subclause conversions should be passed '. 410 + 'a list of PhutilQueryString objects.', 411 + $value, 412 + $query, 413 + $type)); 414 + break; 415 + } 416 + 417 + if (!($value instanceof PhutilQueryString)) { 418 + throw new AphrontParameterQueryException( 419 + $query, 420 + pht( 421 + 'Expected a list of PhutilQueryString objects for %%%s '. 422 + 'conversion.', 423 + $type)); 424 + } 425 + break; 426 + 427 + case 'Q': 428 + // TODO: See T13217. Remove this eventually. 429 + if (is_string($value)) { 430 + phlog( 431 + pht( 432 + 'UNSAFE: Raw string ("%s") passed to query ("%s") for "%%Q" '. 433 + 'conversion. %%Q should be passed a query string.', 434 + $value, 435 + $query)); 436 + break; 437 + } 438 + 439 + if (!($value instanceof PhutilQueryString)) { 440 + throw new AphrontParameterQueryException( 441 + $query, 442 + pht('Expected a PhutilQueryString for %%%s conversion.', $type)); 443 + } 444 + break; 445 + 446 + case 'Z': 447 + if (!is_string($value)) { 448 + throw new AphrontParameterQueryException( 449 + $query, 450 + pht('Value for "%%Z" conversion should be a raw string.')); 451 + } 452 + break; 453 + 454 + case 'LC': 455 + case 'LK': 456 + case 'T': 457 + case 'C': 458 + if (!is_string($value)) { 459 + throw new AphrontParameterQueryException( 460 + $query, 461 + pht('Expected a string for %%%s conversion.', $type)); 462 + } 463 + break; 464 + 465 + case 'Ld': 466 + case 'Lf': 467 + case 'd': 468 + case 'f': 469 + if (!is_null($value) && !is_numeric($value)) { 470 + throw new AphrontParameterQueryException( 471 + $query, 472 + pht('Expected a numeric scalar or null for %%%s conversion.', $type)); 473 + } 474 + break; 475 + 476 + case 'Ls': 477 + case 's': 478 + case 'LB': 479 + case 'B': 480 + case '~': 481 + case '>': 482 + case '<': 483 + case 'K': 484 + if (!is_null($value) && !is_scalar($value)) { 485 + throw new AphrontParameterQueryException( 486 + $query, 487 + pht('Expected a scalar or null for %%%s conversion.', $type)); 488 + } 489 + break; 490 + 491 + case 'R': 492 + if (!($value instanceof AphrontDatabaseTableRefInterface)) { 493 + throw new AphrontParameterQueryException( 494 + $query, 495 + pht( 496 + 'Parameter to "%s" conversion in "qsprintf(...)" is not an '. 497 + 'instance of AphrontDatabaseTableRefInterface.', 498 + '%R')); 499 + } 500 + break; 501 + 502 + case 'P': 503 + if (!($value instanceof PhutilOpaqueEnvelope)) { 504 + throw new AphrontParameterQueryException( 505 + $query, 506 + pht( 507 + 'Parameter to "%s" conversion in "qsprintf(...)" is not an '. 508 + 'instance of PhutilOpaqueEnvelope.', 509 + '%P')); 510 + } 511 + break; 512 + 513 + default: 514 + throw new XsprintfUnknownConversionException($type); 515 + } 516 + }
+27
src/infrastructure/storage/xsprintf/queryfx.php
··· 1 + <?php 2 + 3 + function queryfx(AphrontDatabaseConnection $conn, $sql /* , ... */) { 4 + $argv = func_get_args(); 5 + $query = call_user_func_array('qsprintf', $argv); 6 + 7 + $conn->setLastActiveEpoch(time()); 8 + $conn->executeQuery($query); 9 + } 10 + 11 + function queryfx_all(AphrontDatabaseConnection $conn, $sql /* , ... */) { 12 + $argv = func_get_args(); 13 + call_user_func_array('queryfx', $argv); 14 + return $conn->selectAllResults(); 15 + } 16 + 17 + function queryfx_one(AphrontDatabaseConnection $conn, $sql /* , ... */) { 18 + $argv = func_get_args(); 19 + $ret = call_user_func_array('queryfx_all', $argv); 20 + if (count($ret) > 1) { 21 + throw new AphrontCountQueryException( 22 + pht('Query returned more than one row.')); 23 + } else if (count($ret)) { 24 + return reset($ret); 25 + } 26 + return null; 27 + }