1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-21 22:32:41 +01:00

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
This commit is contained in:
epriestley 2019-09-02 06:20:20 -07:00
parent b2b17485b9
commit 9316cbf7fd
232 changed files with 17837 additions and 1 deletions

View file

@ -1,7 +1,8 @@
{
"exclude": [
"(^externals/)",
"(^webroot/rsrc/externals/(?!javelin/))"
"(^webroot/rsrc/externals/(?!javelin/))",
"(/__tests__/data/)"
],
"linters": {
"chmod": {

View file

@ -176,15 +176,27 @@ phutil_register_library_map(array(
'Aphront400Response' => 'aphront/response/Aphront400Response.php',
'Aphront403Response' => 'aphront/response/Aphront403Response.php',
'Aphront404Response' => 'aphront/response/Aphront404Response.php',
'AphrontAccessDeniedQueryException' => 'infrastructure/storage/exception/AphrontAccessDeniedQueryException.php',
'AphrontAjaxResponse' => 'aphront/response/AphrontAjaxResponse.php',
'AphrontApplicationConfiguration' => 'aphront/configuration/AphrontApplicationConfiguration.php',
'AphrontBarView' => 'view/widget/bars/AphrontBarView.php',
'AphrontBaseMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php',
'AphrontBoolHTTPParameterType' => 'aphront/httpparametertype/AphrontBoolHTTPParameterType.php',
'AphrontCalendarEventView' => 'applications/calendar/view/AphrontCalendarEventView.php',
'AphrontCharacterSetQueryException' => 'infrastructure/storage/exception/AphrontCharacterSetQueryException.php',
'AphrontConnectionLostQueryException' => 'infrastructure/storage/exception/AphrontConnectionLostQueryException.php',
'AphrontConnectionQueryException' => 'infrastructure/storage/exception/AphrontConnectionQueryException.php',
'AphrontController' => 'aphront/AphrontController.php',
'AphrontCountQueryException' => 'infrastructure/storage/exception/AphrontCountQueryException.php',
'AphrontCursorPagerView' => 'view/control/AphrontCursorPagerView.php',
'AphrontDatabaseConnection' => 'infrastructure/storage/connection/AphrontDatabaseConnection.php',
'AphrontDatabaseTableRef' => 'infrastructure/storage/xsprintf/AphrontDatabaseTableRef.php',
'AphrontDatabaseTableRefInterface' => 'infrastructure/storage/xsprintf/AphrontDatabaseTableRefInterface.php',
'AphrontDatabaseTransactionState' => 'infrastructure/storage/connection/AphrontDatabaseTransactionState.php',
'AphrontDeadlockQueryException' => 'infrastructure/storage/exception/AphrontDeadlockQueryException.php',
'AphrontDialogResponse' => 'aphront/response/AphrontDialogResponse.php',
'AphrontDialogView' => 'view/AphrontDialogView.php',
'AphrontDuplicateKeyQueryException' => 'infrastructure/storage/exception/AphrontDuplicateKeyQueryException.php',
'AphrontEpochHTTPParameterType' => 'aphront/httpparametertype/AphrontEpochHTTPParameterType.php',
'AphrontException' => 'aphront/exception/AphrontException.php',
'AphrontFileHTTPParameterType' => 'aphront/httpparametertype/AphrontFileHTTPParameterType.php',
@ -217,6 +229,8 @@ phutil_register_library_map(array(
'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php',
'AphrontHTTPSinkTestCase' => 'aphront/sink/__tests__/AphrontHTTPSinkTestCase.php',
'AphrontIntHTTPParameterType' => 'aphront/httpparametertype/AphrontIntHTTPParameterType.php',
'AphrontInvalidCredentialsQueryException' => 'infrastructure/storage/exception/AphrontInvalidCredentialsQueryException.php',
'AphrontIsolatedDatabaseConnection' => 'infrastructure/storage/connection/AphrontIsolatedDatabaseConnection.php',
'AphrontIsolatedDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontIsolatedDatabaseConnectionTestCase.php',
'AphrontIsolatedHTTPSink' => 'aphront/sink/AphrontIsolatedHTTPSink.php',
'AphrontJSONResponse' => 'aphront/response/AphrontJSONResponse.php',
@ -224,19 +238,28 @@ phutil_register_library_map(array(
'AphrontKeyboardShortcutsAvailableView' => 'view/widget/AphrontKeyboardShortcutsAvailableView.php',
'AphrontListFilterView' => 'view/layout/AphrontListFilterView.php',
'AphrontListHTTPParameterType' => 'aphront/httpparametertype/AphrontListHTTPParameterType.php',
'AphrontLockTimeoutQueryException' => 'infrastructure/storage/exception/AphrontLockTimeoutQueryException.php',
'AphrontMalformedRequestException' => 'aphront/exception/AphrontMalformedRequestException.php',
'AphrontMoreView' => 'view/layout/AphrontMoreView.php',
'AphrontMultiColumnView' => 'view/layout/AphrontMultiColumnView.php',
'AphrontMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLDatabaseConnection.php',
'AphrontMySQLDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontMySQLDatabaseConnectionTestCase.php',
'AphrontMySQLiDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php',
'AphrontNotSupportedQueryException' => 'infrastructure/storage/exception/AphrontNotSupportedQueryException.php',
'AphrontNullView' => 'view/AphrontNullView.php',
'AphrontObjectMissingQueryException' => 'infrastructure/storage/exception/AphrontObjectMissingQueryException.php',
'AphrontPHIDHTTPParameterType' => 'aphront/httpparametertype/AphrontPHIDHTTPParameterType.php',
'AphrontPHIDListHTTPParameterType' => 'aphront/httpparametertype/AphrontPHIDListHTTPParameterType.php',
'AphrontPHPHTTPSink' => 'aphront/sink/AphrontPHPHTTPSink.php',
'AphrontPageView' => 'view/page/AphrontPageView.php',
'AphrontParameterQueryException' => 'infrastructure/storage/exception/AphrontParameterQueryException.php',
'AphrontPlainTextResponse' => 'aphront/response/AphrontPlainTextResponse.php',
'AphrontProgressBarView' => 'view/widget/bars/AphrontProgressBarView.php',
'AphrontProjectListHTTPParameterType' => 'aphront/httpparametertype/AphrontProjectListHTTPParameterType.php',
'AphrontProxyResponse' => 'aphront/response/AphrontProxyResponse.php',
'AphrontQueryException' => 'infrastructure/storage/exception/AphrontQueryException.php',
'AphrontQueryTimeoutQueryException' => 'infrastructure/storage/exception/AphrontQueryTimeoutQueryException.php',
'AphrontRecoverableQueryException' => 'infrastructure/storage/exception/AphrontRecoverableQueryException.php',
'AphrontRedirectResponse' => 'aphront/response/AphrontRedirectResponse.php',
'AphrontRedirectResponseTestCase' => 'aphront/response/__tests__/AphrontRedirectResponseTestCase.php',
'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php',
@ -247,6 +270,7 @@ phutil_register_library_map(array(
'AphrontResponseProducerInterface' => 'aphront/interface/AphrontResponseProducerInterface.php',
'AphrontRoutingMap' => 'aphront/site/AphrontRoutingMap.php',
'AphrontRoutingResult' => 'aphront/site/AphrontRoutingResult.php',
'AphrontSchemaQueryException' => 'infrastructure/storage/exception/AphrontSchemaQueryException.php',
'AphrontSelectHTTPParameterType' => 'aphront/httpparametertype/AphrontSelectHTTPParameterType.php',
'AphrontSideNavFilterView' => 'view/layout/AphrontSideNavFilterView.php',
'AphrontSite' => 'aphront/site/AphrontSite.php',
@ -5512,6 +5536,93 @@ phutil_register_library_map(array(
'PhrictionTransactionComment' => 'applications/phriction/storage/PhrictionTransactionComment.php',
'PhrictionTransactionEditor' => 'applications/phriction/editor/PhrictionTransactionEditor.php',
'PhrictionTransactionQuery' => 'applications/phriction/query/PhrictionTransactionQuery.php',
'PhutilAmazonAuthAdapter' => 'applications/auth/adapter/PhutilAmazonAuthAdapter.php',
'PhutilAsanaAuthAdapter' => 'applications/auth/adapter/PhutilAsanaAuthAdapter.php',
'PhutilAuthAdapter' => 'applications/auth/adapter/PhutilAuthAdapter.php',
'PhutilAuthConfigurationException' => 'applications/auth/exception/PhutilAuthConfigurationException.php',
'PhutilAuthCredentialException' => 'applications/auth/exception/PhutilAuthCredentialException.php',
'PhutilAuthException' => 'applications/auth/exception/PhutilAuthException.php',
'PhutilAuthUserAbortedException' => 'applications/auth/exception/PhutilAuthUserAbortedException.php',
'PhutilBitbucketAuthAdapter' => 'applications/auth/adapter/PhutilBitbucketAuthAdapter.php',
'PhutilCLikeCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilCLikeCodeSnippetContextFreeGrammar.php',
'PhutilCalendarAbsoluteDateTime' => 'applications/calendar/parser/data/PhutilCalendarAbsoluteDateTime.php',
'PhutilCalendarContainerNode' => 'applications/calendar/parser/data/PhutilCalendarContainerNode.php',
'PhutilCalendarDateTime' => 'applications/calendar/parser/data/PhutilCalendarDateTime.php',
'PhutilCalendarDateTimeTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarDateTimeTestCase.php',
'PhutilCalendarDocumentNode' => 'applications/calendar/parser/data/PhutilCalendarDocumentNode.php',
'PhutilCalendarDuration' => 'applications/calendar/parser/data/PhutilCalendarDuration.php',
'PhutilCalendarEventNode' => 'applications/calendar/parser/data/PhutilCalendarEventNode.php',
'PhutilCalendarNode' => 'applications/calendar/parser/data/PhutilCalendarNode.php',
'PhutilCalendarProxyDateTime' => 'applications/calendar/parser/data/PhutilCalendarProxyDateTime.php',
'PhutilCalendarRawNode' => 'applications/calendar/parser/data/PhutilCalendarRawNode.php',
'PhutilCalendarRecurrenceList' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceList.php',
'PhutilCalendarRecurrenceRule' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php',
'PhutilCalendarRecurrenceRuleTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php',
'PhutilCalendarRecurrenceSet' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceSet.php',
'PhutilCalendarRecurrenceSource' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceSource.php',
'PhutilCalendarRecurrenceTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceTestCase.php',
'PhutilCalendarRelativeDateTime' => 'applications/calendar/parser/data/PhutilCalendarRelativeDateTime.php',
'PhutilCalendarRootNode' => 'applications/calendar/parser/data/PhutilCalendarRootNode.php',
'PhutilCalendarUserNode' => 'applications/calendar/parser/data/PhutilCalendarUserNode.php',
'PhutilCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilCodeSnippetContextFreeGrammar.php',
'PhutilDisqusAuthAdapter' => 'applications/auth/adapter/PhutilDisqusAuthAdapter.php',
'PhutilEmptyAuthAdapter' => 'applications/auth/adapter/PhutilEmptyAuthAdapter.php',
'PhutilFacebookAuthAdapter' => 'applications/auth/adapter/PhutilFacebookAuthAdapter.php',
'PhutilGitHubAuthAdapter' => 'applications/auth/adapter/PhutilGitHubAuthAdapter.php',
'PhutilGoogleAuthAdapter' => 'applications/auth/adapter/PhutilGoogleAuthAdapter.php',
'PhutilICSParser' => 'applications/calendar/parser/ics/PhutilICSParser.php',
'PhutilICSParserException' => 'applications/calendar/parser/ics/PhutilICSParserException.php',
'PhutilICSParserTestCase' => 'applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php',
'PhutilICSWriter' => 'applications/calendar/parser/ics/PhutilICSWriter.php',
'PhutilICSWriterTestCase' => 'applications/calendar/parser/ics/__tests__/PhutilICSWriterTestCase.php',
'PhutilJIRAAuthAdapter' => 'applications/auth/adapter/PhutilJIRAAuthAdapter.php',
'PhutilJavaCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilJavaCodeSnippetContextFreeGrammar.php',
'PhutilLDAPAuthAdapter' => 'applications/auth/adapter/PhutilLDAPAuthAdapter.php',
'PhutilLipsumContextFreeGrammar' => 'infrastructure/lipsum/PhutilLipsumContextFreeGrammar.php',
'PhutilOAuth1AuthAdapter' => 'applications/auth/adapter/PhutilOAuth1AuthAdapter.php',
'PhutilOAuthAuthAdapter' => 'applications/auth/adapter/PhutilOAuthAuthAdapter.php',
'PhutilPHPCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilPHPCodeSnippetContextFreeGrammar.php',
'PhutilPhabricatorAuthAdapter' => 'applications/auth/adapter/PhutilPhabricatorAuthAdapter.php',
'PhutilQsprintfInterface' => 'infrastructure/storage/xsprintf/PhutilQsprintfInterface.php',
'PhutilQueryString' => 'infrastructure/storage/xsprintf/PhutilQueryString.php',
'PhutilRealNameContextFreeGrammar' => 'infrastructure/lipsum/PhutilRealNameContextFreeGrammar.php',
'PhutilRemarkupBlockInterpreter' => 'infrastructure/markup/blockrule/PhutilRemarkupBlockInterpreter.php',
'PhutilRemarkupBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php',
'PhutilRemarkupBlockStorage' => 'infrastructure/markup/PhutilRemarkupBlockStorage.php',
'PhutilRemarkupBoldRule' => 'infrastructure/markup/markuprule/PhutilRemarkupBoldRule.php',
'PhutilRemarkupCodeBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php',
'PhutilRemarkupDefaultBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupDefaultBlockRule.php',
'PhutilRemarkupDelRule' => 'infrastructure/markup/markuprule/PhutilRemarkupDelRule.php',
'PhutilRemarkupDocumentLinkRule' => 'infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php',
'PhutilRemarkupEngine' => 'infrastructure/markup/remarkup/PhutilRemarkupEngine.php',
'PhutilRemarkupEngineTestCase' => 'infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php',
'PhutilRemarkupEscapeRemarkupRule' => 'infrastructure/markup/markuprule/PhutilRemarkupEscapeRemarkupRule.php',
'PhutilRemarkupHeaderBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupHeaderBlockRule.php',
'PhutilRemarkupHighlightRule' => 'infrastructure/markup/markuprule/PhutilRemarkupHighlightRule.php',
'PhutilRemarkupHorizontalRuleBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupHorizontalRuleBlockRule.php',
'PhutilRemarkupHyperlinkEngineExtension' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php',
'PhutilRemarkupHyperlinkRef' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRef.php',
'PhutilRemarkupHyperlinkRule' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRule.php',
'PhutilRemarkupInlineBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupInlineBlockRule.php',
'PhutilRemarkupInterpreterBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupInterpreterBlockRule.php',
'PhutilRemarkupItalicRule' => 'infrastructure/markup/markuprule/PhutilRemarkupItalicRule.php',
'PhutilRemarkupLinebreaksRule' => 'infrastructure/markup/markuprule/PhutilRemarkupLinebreaksRule.php',
'PhutilRemarkupListBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php',
'PhutilRemarkupLiteralBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php',
'PhutilRemarkupMonospaceRule' => 'infrastructure/markup/markuprule/PhutilRemarkupMonospaceRule.php',
'PhutilRemarkupNoteBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupNoteBlockRule.php',
'PhutilRemarkupQuotedBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php',
'PhutilRemarkupQuotesBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupQuotesBlockRule.php',
'PhutilRemarkupReplyBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupReplyBlockRule.php',
'PhutilRemarkupRule' => 'infrastructure/markup/markuprule/PhutilRemarkupRule.php',
'PhutilRemarkupSimpleTableBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupSimpleTableBlockRule.php',
'PhutilRemarkupTableBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupTableBlockRule.php',
'PhutilRemarkupTestInterpreterRule' => 'infrastructure/markup/blockrule/PhutilRemarkupTestInterpreterRule.php',
'PhutilRemarkupUnderlineRule' => 'infrastructure/markup/markuprule/PhutilRemarkupUnderlineRule.php',
'PhutilSlackAuthAdapter' => 'applications/auth/adapter/PhutilSlackAuthAdapter.php',
'PhutilTwitchAuthAdapter' => 'applications/auth/adapter/PhutilTwitchAuthAdapter.php',
'PhutilTwitterAuthAdapter' => 'applications/auth/adapter/PhutilTwitterAuthAdapter.php',
'PhutilWordPressAuthAdapter' => 'applications/auth/adapter/PhutilWordPressAuthAdapter.php',
'PolicyLockOptionType' => 'applications/policy/config/PolicyLockOptionType.php',
'PonderAddAnswerView' => 'applications/ponder/view/PonderAddAnswerView.php',
'PonderAnswer' => 'applications/ponder/storage/PonderAnswer.php',
@ -5587,6 +5698,7 @@ phutil_register_library_map(array(
'ProjectReplyHandler' => 'applications/project/mail/ProjectReplyHandler.php',
'ProjectSearchConduitAPIMethod' => 'applications/project/conduit/ProjectSearchConduitAPIMethod.php',
'QueryFormattingTestCase' => 'infrastructure/storage/__tests__/QueryFormattingTestCase.php',
'QueryFuture' => 'infrastructure/storage/future/QueryFuture.php',
'ReleephAuthorFieldSpecification' => 'applications/releeph/field/specification/ReleephAuthorFieldSpecification.php',
'ReleephBranch' => 'applications/releeph/storage/ReleephBranch.php',
'ReleephBranchAccessController' => 'applications/releeph/controller/branch/ReleephBranchAccessController.php',
@ -5713,7 +5825,15 @@ phutil_register_library_map(array(
'phid_get_subtype' => 'applications/phid/utils.php',
'phid_get_type' => 'applications/phid/utils.php',
'phid_group_by_type' => 'applications/phid/utils.php',
'qsprintf' => 'infrastructure/storage/xsprintf/qsprintf.php',
'qsprintf_check_scalar_type' => 'infrastructure/storage/xsprintf/qsprintf.php',
'qsprintf_check_type' => 'infrastructure/storage/xsprintf/qsprintf.php',
'queryfx' => 'infrastructure/storage/xsprintf/queryfx.php',
'queryfx_all' => 'infrastructure/storage/xsprintf/queryfx.php',
'queryfx_one' => 'infrastructure/storage/xsprintf/queryfx.php',
'require_celerity_resource' => 'applications/celerity/api.php',
'vqsprintf' => 'infrastructure/storage/xsprintf/qsprintf.php',
'xsprintf_query' => 'infrastructure/storage/xsprintf/qsprintf.php',
),
'xmap' => array(
'AlmanacAddress' => 'Phobject',
@ -5937,18 +6057,35 @@ phutil_register_library_map(array(
'Aphront400Response' => 'AphrontResponse',
'Aphront403Response' => 'AphrontHTMLResponse',
'Aphront404Response' => 'AphrontHTMLResponse',
'AphrontAccessDeniedQueryException' => 'AphrontQueryException',
'AphrontAjaxResponse' => 'AphrontResponse',
'AphrontApplicationConfiguration' => 'Phobject',
'AphrontBarView' => 'AphrontView',
'AphrontBaseMySQLDatabaseConnection' => 'AphrontDatabaseConnection',
'AphrontBoolHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontCalendarEventView' => 'AphrontView',
'AphrontCharacterSetQueryException' => 'AphrontQueryException',
'AphrontConnectionLostQueryException' => 'AphrontRecoverableQueryException',
'AphrontConnectionQueryException' => 'AphrontQueryException',
'AphrontController' => 'Phobject',
'AphrontCountQueryException' => 'AphrontQueryException',
'AphrontCursorPagerView' => 'AphrontView',
'AphrontDatabaseConnection' => array(
'Phobject',
'PhutilQsprintfInterface',
),
'AphrontDatabaseTableRef' => array(
'Phobject',
'AphrontDatabaseTableRefInterface',
),
'AphrontDatabaseTransactionState' => 'Phobject',
'AphrontDeadlockQueryException' => 'AphrontRecoverableQueryException',
'AphrontDialogResponse' => 'AphrontResponse',
'AphrontDialogView' => array(
'AphrontView',
'AphrontResponseProducerInterface',
),
'AphrontDuplicateKeyQueryException' => 'AphrontQueryException',
'AphrontEpochHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontException' => 'Exception',
'AphrontFileHTTPParameterType' => 'AphrontHTTPParameterType',
@ -5981,6 +6118,8 @@ phutil_register_library_map(array(
'AphrontHTTPSink' => 'Phobject',
'AphrontHTTPSinkTestCase' => 'PhabricatorTestCase',
'AphrontIntHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontInvalidCredentialsQueryException' => 'AphrontQueryException',
'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection',
'AphrontIsolatedDatabaseConnectionTestCase' => 'PhabricatorTestCase',
'AphrontIsolatedHTTPSink' => 'AphrontHTTPSink',
'AphrontJSONResponse' => 'AphrontResponse',
@ -5988,15 +6127,21 @@ phutil_register_library_map(array(
'AphrontKeyboardShortcutsAvailableView' => 'AphrontView',
'AphrontListFilterView' => 'AphrontView',
'AphrontListHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontLockTimeoutQueryException' => 'AphrontRecoverableQueryException',
'AphrontMalformedRequestException' => 'AphrontException',
'AphrontMoreView' => 'AphrontView',
'AphrontMultiColumnView' => 'AphrontView',
'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
'AphrontMySQLDatabaseConnectionTestCase' => 'PhabricatorTestCase',
'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
'AphrontNotSupportedQueryException' => 'AphrontQueryException',
'AphrontNullView' => 'AphrontView',
'AphrontObjectMissingQueryException' => 'AphrontQueryException',
'AphrontPHIDHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontPHIDListHTTPParameterType' => 'AphrontListHTTPParameterType',
'AphrontPHPHTTPSink' => 'AphrontHTTPSink',
'AphrontPageView' => 'AphrontView',
'AphrontParameterQueryException' => 'AphrontQueryException',
'AphrontPlainTextResponse' => 'AphrontResponse',
'AphrontProgressBarView' => 'AphrontBarView',
'AphrontProjectListHTTPParameterType' => 'AphrontListHTTPParameterType',
@ -6004,6 +6149,9 @@ phutil_register_library_map(array(
'AphrontResponse',
'AphrontResponseProducerInterface',
),
'AphrontQueryException' => 'Exception',
'AphrontQueryTimeoutQueryException' => 'AphrontRecoverableQueryException',
'AphrontRecoverableQueryException' => 'AphrontQueryException',
'AphrontRedirectResponse' => 'AphrontResponse',
'AphrontRedirectResponseTestCase' => 'PhabricatorTestCase',
'AphrontReloadResponse' => 'AphrontRedirectResponse',
@ -6013,6 +6161,7 @@ phutil_register_library_map(array(
'AphrontResponse' => 'Phobject',
'AphrontRoutingMap' => 'Phobject',
'AphrontRoutingResult' => 'Phobject',
'AphrontSchemaQueryException' => 'AphrontQueryException',
'AphrontSelectHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontSideNavFilterView' => 'AphrontView',
'AphrontSite' => 'Phobject',
@ -12169,6 +12318,92 @@ phutil_register_library_map(array(
'PhrictionTransactionComment' => 'PhabricatorApplicationTransactionComment',
'PhrictionTransactionEditor' => 'PhabricatorApplicationTransactionEditor',
'PhrictionTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
'PhutilAmazonAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilAsanaAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilAuthAdapter' => 'Phobject',
'PhutilAuthConfigurationException' => 'PhutilAuthException',
'PhutilAuthCredentialException' => 'PhutilAuthException',
'PhutilAuthException' => 'Exception',
'PhutilAuthUserAbortedException' => 'PhutilAuthException',
'PhutilBitbucketAuthAdapter' => 'PhutilOAuth1AuthAdapter',
'PhutilCLikeCodeSnippetContextFreeGrammar' => 'PhutilCodeSnippetContextFreeGrammar',
'PhutilCalendarAbsoluteDateTime' => 'PhutilCalendarDateTime',
'PhutilCalendarContainerNode' => 'PhutilCalendarNode',
'PhutilCalendarDateTime' => 'Phobject',
'PhutilCalendarDateTimeTestCase' => 'PhutilTestCase',
'PhutilCalendarDocumentNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarDuration' => 'Phobject',
'PhutilCalendarEventNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarNode' => 'Phobject',
'PhutilCalendarProxyDateTime' => 'PhutilCalendarDateTime',
'PhutilCalendarRawNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarRecurrenceList' => 'PhutilCalendarRecurrenceSource',
'PhutilCalendarRecurrenceRule' => 'PhutilCalendarRecurrenceSource',
'PhutilCalendarRecurrenceRuleTestCase' => 'PhutilTestCase',
'PhutilCalendarRecurrenceSet' => 'Phobject',
'PhutilCalendarRecurrenceSource' => 'Phobject',
'PhutilCalendarRecurrenceTestCase' => 'PhutilTestCase',
'PhutilCalendarRelativeDateTime' => 'PhutilCalendarProxyDateTime',
'PhutilCalendarRootNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarUserNode' => 'PhutilCalendarNode',
'PhutilCodeSnippetContextFreeGrammar' => 'PhutilContextFreeGrammar',
'PhutilDisqusAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilEmptyAuthAdapter' => 'PhutilAuthAdapter',
'PhutilFacebookAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilGitHubAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilGoogleAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilICSParser' => 'Phobject',
'PhutilICSParserException' => 'Exception',
'PhutilICSParserTestCase' => 'PhutilTestCase',
'PhutilICSWriter' => 'Phobject',
'PhutilICSWriterTestCase' => 'PhutilTestCase',
'PhutilJIRAAuthAdapter' => 'PhutilOAuth1AuthAdapter',
'PhutilJavaCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar',
'PhutilLDAPAuthAdapter' => 'PhutilAuthAdapter',
'PhutilLipsumContextFreeGrammar' => 'PhutilContextFreeGrammar',
'PhutilOAuth1AuthAdapter' => 'PhutilAuthAdapter',
'PhutilOAuthAuthAdapter' => 'PhutilAuthAdapter',
'PhutilPHPCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar',
'PhutilPhabricatorAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilQueryString' => 'Phobject',
'PhutilRealNameContextFreeGrammar' => 'PhutilContextFreeGrammar',
'PhutilRemarkupBlockInterpreter' => 'Phobject',
'PhutilRemarkupBlockRule' => 'Phobject',
'PhutilRemarkupBlockStorage' => 'Phobject',
'PhutilRemarkupBoldRule' => 'PhutilRemarkupRule',
'PhutilRemarkupCodeBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupDefaultBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupDelRule' => 'PhutilRemarkupRule',
'PhutilRemarkupDocumentLinkRule' => 'PhutilRemarkupRule',
'PhutilRemarkupEngine' => 'PhutilMarkupEngine',
'PhutilRemarkupEngineTestCase' => 'PhutilTestCase',
'PhutilRemarkupEscapeRemarkupRule' => 'PhutilRemarkupRule',
'PhutilRemarkupHeaderBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupHighlightRule' => 'PhutilRemarkupRule',
'PhutilRemarkupHorizontalRuleBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupHyperlinkEngineExtension' => 'Phobject',
'PhutilRemarkupHyperlinkRef' => 'Phobject',
'PhutilRemarkupHyperlinkRule' => 'PhutilRemarkupRule',
'PhutilRemarkupInlineBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupInterpreterBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupItalicRule' => 'PhutilRemarkupRule',
'PhutilRemarkupLinebreaksRule' => 'PhutilRemarkupRule',
'PhutilRemarkupListBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupLiteralBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupMonospaceRule' => 'PhutilRemarkupRule',
'PhutilRemarkupNoteBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupQuotedBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupQuotesBlockRule' => 'PhutilRemarkupQuotedBlockRule',
'PhutilRemarkupReplyBlockRule' => 'PhutilRemarkupQuotedBlockRule',
'PhutilRemarkupRule' => 'Phobject',
'PhutilRemarkupSimpleTableBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupTableBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupTestInterpreterRule' => 'PhutilRemarkupBlockInterpreter',
'PhutilRemarkupUnderlineRule' => 'PhutilRemarkupRule',
'PhutilSlackAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilTwitchAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilTwitterAuthAdapter' => 'PhutilOAuth1AuthAdapter',
'PhutilWordPressAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PolicyLockOptionType' => 'PhabricatorConfigJSONOptionType',
'PonderAddAnswerView' => 'AphrontView',
'PonderAnswer' => array(
@ -12265,6 +12500,7 @@ phutil_register_library_map(array(
'ProjectReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler',
'ProjectSearchConduitAPIMethod' => 'PhabricatorSearchEngineAPIMethod',
'QueryFormattingTestCase' => 'PhabricatorTestCase',
'QueryFuture' => 'Future',
'ReleephAuthorFieldSpecification' => 'ReleephFieldSpecification',
'ReleephBranch' => array(
'ReleephDAO',

View file

@ -0,0 +1,80 @@
<?php
/**
* Authentication adapter for Amazon OAuth2.
*/
final class PhutilAmazonAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'amazon';
}
public function getAdapterDomain() {
return 'amazon.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('user_id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return null;
}
public function getAccountImageURI() {
return null;
}
public function getAccountURI() {
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://www.amazon.com/ap/oa';
}
protected function getTokenBaseURI() {
return 'https://api.amazon.com/auth/o2/token';
}
public function getScope() {
return 'profile';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://api.amazon.com/user/profile');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$future = new HTTPSFuture($uri);
list($body) = $future->resolvex();
try {
return phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Amazon account data request.'),
$ex);
}
}
}

View file

@ -0,0 +1,86 @@
<?php
/**
* Authentication adapter for Asana OAuth2.
*/
final class PhutilAsanaAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'asana';
}
public function getAdapterDomain() {
return 'asana.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return null;
}
public function getAccountImageURI() {
$photo = $this->getOAuthAccountData('photo', array());
if (is_array($photo)) {
return idx($photo, 'image_128x128');
} else {
return null;
}
}
public function getAccountURI() {
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://app.asana.com/-/oauth_authorize';
}
protected function getTokenBaseURI() {
return 'https://app.asana.com/-/oauth_token';
}
public function getScope() {
return null;
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
public function getExtraRefreshParameters() {
return array(
'grant_type' => 'refresh_token',
);
}
public function supportsTokenRefresh() {
return true;
}
protected function loadOAuthAccountData() {
return id(new PhutilAsanaFuture())
->setAccessToken($this->getAccessToken())
->setRawAsanaQuery('users/me')
->resolve();
}
}

View file

@ -0,0 +1,123 @@
<?php
/**
* Abstract interface to an identity provider or authentication source, like
* Twitter, Facebook or Google.
*
* Generally, adapters are handed some set of credentials particular to the
* provider they adapt, and they turn those credentials into standard
* information about the user's identity. For example, the LDAP adapter is given
* a username and password (and some other configuration information), uses them
* to talk to the LDAP server, and produces a username, email, and so forth.
*
* Since the credentials a provider requires are specific to each provider, the
* base adapter does not specify how an adapter should be constructed or
* configured -- only what information it is expected to be able to provide once
* properly configured.
*/
abstract class PhutilAuthAdapter extends Phobject {
/**
* Get a unique identifier associated with the identity. For most providers,
* this is an account ID.
*
* The account ID needs to be unique within this adapter's configuration, such
* that `<adapterKey, accountID>` is globally unique and always identifies the
* same identity.
*
* If the adapter was unable to authenticate an identity, it should return
* `null`.
*
* @return string|null Unique account identifier, or `null` if authentication
* failed.
*/
abstract public function getAccountID();
/**
* Get a string identifying this adapter, like "ldap". This string should be
* unique to the adapter class.
*
* @return string Unique adapter identifier.
*/
abstract public function getAdapterType();
/**
* Get a string identifying the domain this adapter is acting on. This allows
* an adapter (like LDAP) to act against different identity domains without
* conflating credentials. For providers like Facebook or Google, the adapters
* just return the relevant domain name.
*
* @return string Domain the adapter is associated with.
*/
abstract public function getAdapterDomain();
/**
* Generate a string uniquely identifying this adapter configuration. Within
* the scope of a given key, all account IDs must uniquely identify exactly
* one identity.
*
* @return string Unique identifier for this adapter configuration.
*/
public function getAdapterKey() {
return $this->getAdapterType().':'.$this->getAdapterDomain();
}
/**
* Optionally, return an email address associated with this account.
*
* @return string|null An email address associated with the account, or
* `null` if data is not available.
*/
public function getAccountEmail() {
return null;
}
/**
* Optionally, return a human readable username associated with this account.
*
* @return string|null Account username, or `null` if data isn't available.
*/
public function getAccountName() {
return null;
}
/**
* Optionally, return a URI corresponding to a human-viewable profile for
* this account.
*
* @return string|null A profile URI associated with this account, or
* `null` if the data isn't available.
*/
public function getAccountURI() {
return null;
}
/**
* Optionally, return a profile image URI associated with this account.
*
* @return string|null URI for an account profile image, or `null` if one is
* not available.
*/
public function getAccountImageURI() {
return null;
}
/**
* Optionally, return a real name associated with this account.
*
* @return string|null A human real name, or `null` if this data is not
* available.
*/
public function getAccountRealName() {
return null;
}
}

View file

@ -0,0 +1,73 @@
<?php
final class PhutilBitbucketAuthAdapter extends PhutilOAuth1AuthAdapter {
private $userInfo;
public function getAccountID() {
return idx($this->getUserInfo(), 'username');
}
public function getAccountName() {
return idx($this->getUserInfo(), 'display_name');
}
public function getAccountURI() {
$name = $this->getAccountID();
if (strlen($name)) {
return 'https://bitbucket.org/'.$name;
}
return null;
}
public function getAccountImageURI() {
return idx($this->getUserInfo(), 'avatar');
}
public function getAccountRealName() {
$parts = array(
idx($this->getUserInfo(), 'first_name'),
idx($this->getUserInfo(), 'last_name'),
);
$parts = array_filter($parts);
return implode(' ', $parts);
}
public function getAdapterType() {
return 'bitbucket';
}
public function getAdapterDomain() {
return 'bitbucket.org';
}
protected function getRequestTokenURI() {
return 'https://bitbucket.org/api/1.0/oauth/request_token';
}
protected function getAuthorizeTokenURI() {
return 'https://bitbucket.org/api/1.0/oauth/authenticate';
}
protected function getValidateTokenURI() {
return 'https://bitbucket.org/api/1.0/oauth/access_token';
}
private function getUserInfo() {
if ($this->userInfo === null) {
// We don't need any of the data in the handshake, but do need to
// finish the process. This makes sure we've completed the handshake.
$this->getHandshakeData();
$uri = new PhutilURI('https://bitbucket.org/api/1.0/user');
$data = $this->newOAuth1Future($uri)
->setMethod('GET')
->resolveJSON();
$this->userInfo = idx($data, 'user', array());
}
return $this->userInfo;
}
}

View file

@ -0,0 +1,84 @@
<?php
/**
* Authentication adapter for Disqus OAuth2.
*/
final class PhutilDisqusAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'disqus';
}
public function getAdapterDomain() {
return 'disqus.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('username');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('avatar', 'permalink');
}
public function getAccountURI() {
return $this->getOAuthAccountData('profileUrl');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://disqus.com/api/oauth/2.0/authorize/';
}
protected function getTokenBaseURI() {
return 'https://disqus.com/api/oauth/2.0/access_token/';
}
public function getScope() {
return 'read';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://disqus.com/api/3.0/users/details.json');
$uri->replaceQueryParam('api_key', $this->getClientID());
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$uri = (string)$uri;
$future = new HTTPSFuture($uri);
$future->setMethod('GET');
list($body) = $future->resolvex();
try {
$data = phutil_json_decode($body);
return $data['response'];
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Disqus account data request.'),
$ex);
}
}
}

View file

@ -0,0 +1,42 @@
<?php
/**
* Empty authentication adapter with no logic.
*
* This adapter can be used when you need an adapter for some technical reason
* but it doesn't make sense to put logic inside it.
*/
final class PhutilEmptyAuthAdapter extends PhutilAuthAdapter {
private $accountID;
private $adapterType;
private $adapterDomain;
public function setAdapterDomain($adapter_domain) {
$this->adapterDomain = $adapter_domain;
return $this;
}
public function getAdapterDomain() {
return $this->adapterDomain;
}
public function setAdapterType($adapter_type) {
$this->adapterType = $adapter_type;
return $this;
}
public function getAdapterType() {
return $this->adapterType;
}
public function setAccountID($account_id) {
$this->accountID = $account_id;
return $this;
}
public function getAccountID() {
return $this->accountID;
}
}

View file

@ -0,0 +1,114 @@
<?php
/**
* Authentication adapter for Facebook OAuth2.
*/
final class PhutilFacebookAuthAdapter extends PhutilOAuthAuthAdapter {
private $requireSecureBrowsing;
public function setRequireSecureBrowsing($require_secure_browsing) {
$this->requireSecureBrowsing = $require_secure_browsing;
return $this;
}
public function getAdapterType() {
return 'facebook';
}
public function getAdapterDomain() {
return 'facebook.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
$link = $this->getOAuthAccountData('link');
if (!$link) {
return null;
}
$matches = null;
if (!preg_match('@/([^/]+)$@', $link, $matches)) {
return null;
}
return $matches[1];
}
public function getAccountImageURI() {
$picture = $this->getOAuthAccountData('picture');
if ($picture) {
$picture_data = idx($picture, 'data');
if ($picture_data) {
return idx($picture_data, 'url');
}
}
return null;
}
public function getAccountURI() {
return $this->getOAuthAccountData('link');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
public function getAccountSecuritySettings() {
return $this->getOAuthAccountData('security_settings');
}
protected function getAuthenticateBaseURI() {
return 'https://www.facebook.com/dialog/oauth';
}
protected function getTokenBaseURI() {
return 'https://graph.facebook.com/oauth/access_token';
}
protected function loadOAuthAccountData() {
$fields = array(
'id',
'name',
'email',
'link',
'security_settings',
'picture',
);
$uri = new PhutilURI('https://graph.facebook.com/me');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$uri->replaceQueryParam('fields', implode(',', $fields));
list($body) = id(new HTTPSFuture($uri))->resolvex();
$data = null;
try {
$data = phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Facebook account data request.'),
$ex);
}
if ($this->requireSecureBrowsing) {
if (empty($data['security_settings']['secure_browsing']['enabled'])) {
throw new Exception(
pht(
'This Phabricator install requires you to enable Secure Browsing '.
'on your Facebook account in order to use it to log in to '.
'Phabricator. For more information, see %s',
'https://www.facebook.com/help/156201551113407/'));
}
}
return $data;
}
}

View file

@ -0,0 +1,72 @@
<?php
/**
* Authentication adapter for Github OAuth2.
*/
final class PhutilGitHubAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'github';
}
public function getAdapterDomain() {
return 'github.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('login');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('avatar_url');
}
public function getAccountURI() {
$name = $this->getAccountName();
if (strlen($name)) {
return 'https://github.com/'.$name;
}
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://github.com/login/oauth/authorize';
}
protected function getTokenBaseURI() {
return 'https://github.com/login/oauth/access_token';
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://api.github.com/user');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$future = new HTTPSFuture($uri);
// NOTE: GitHub requires a User-Agent string.
$future->addHeader('User-Agent', __CLASS__);
list($body) = $future->resolvex();
try {
return phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from GitHub account data request.'),
$ex);
}
}
}

View file

@ -0,0 +1,105 @@
<?php
/**
* Authentication adapter for Google OAuth2.
*/
final class PhutilGoogleAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'google';
}
public function getAdapterDomain() {
return 'google.com';
}
public function getAccountID() {
return $this->getAccountEmail();
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
// Guess account name from email address, this is just a hint anyway.
$email = $this->getAccountEmail();
$email = explode('@', $email);
$email = head($email);
return $email;
}
public function getAccountImageURI() {
$uri = $this->getOAuthAccountData('picture');
// Change the "sz" parameter ("size") from the default to 100 to ask for
// a 100x100px image.
if ($uri !== null) {
$uri = new PhutilURI($uri);
$uri->replaceQueryParam('sz', 100);
$uri = (string)$uri;
}
return $uri;
}
public function getAccountURI() {
return $this->getOAuthAccountData('link');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://accounts.google.com/o/oauth2/auth';
}
protected function getTokenBaseURI() {
return 'https://accounts.google.com/o/oauth2/token';
}
public function getScope() {
$scopes = array(
'email',
'profile',
);
return implode(' ', $scopes);
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://www.googleapis.com/userinfo/v2/me');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$future = new HTTPSFuture($uri);
list($status, $body) = $future->resolve();
if ($status->isError()) {
throw $status;
}
try {
$result = phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Google account data request.'),
$ex);
}
return $result;
}
}

View file

@ -0,0 +1,164 @@
<?php
/**
* Authentication adapter for JIRA OAuth1.
*/
final class PhutilJIRAAuthAdapter extends PhutilOAuth1AuthAdapter {
// TODO: JIRA tokens expire (after 5 years) and we could surface and store
// that.
private $jiraBaseURI;
private $adapterDomain;
private $currentSession;
private $userInfo;
public function setJIRABaseURI($jira_base_uri) {
$this->jiraBaseURI = $jira_base_uri;
return $this;
}
public function getJIRABaseURI() {
return $this->jiraBaseURI;
}
public function getAccountID() {
// Make sure the handshake is finished; this method is used for its
// side effect by Auth providers.
$this->getHandshakeData();
return idx($this->getUserInfo(), 'key');
}
public function getAccountName() {
return idx($this->getUserInfo(), 'name');
}
public function getAccountImageURI() {
$avatars = idx($this->getUserInfo(), 'avatarUrls');
if ($avatars) {
return idx($avatars, '48x48');
}
return null;
}
public function getAccountRealName() {
return idx($this->getUserInfo(), 'displayName');
}
public function getAccountEmail() {
return idx($this->getUserInfo(), 'emailAddress');
}
public function getAdapterType() {
return 'jira';
}
public function getAdapterDomain() {
return $this->adapterDomain;
}
public function setAdapterDomain($domain) {
$this->adapterDomain = $domain;
return $this;
}
protected function getSignatureMethod() {
return 'RSA-SHA1';
}
protected function getRequestTokenURI() {
return $this->getJIRAURI('plugins/servlet/oauth/request-token');
}
protected function getAuthorizeTokenURI() {
return $this->getJIRAURI('plugins/servlet/oauth/authorize');
}
protected function getValidateTokenURI() {
return $this->getJIRAURI('plugins/servlet/oauth/access-token');
}
private function getJIRAURI($path) {
return rtrim($this->jiraBaseURI, '/').'/'.ltrim($path, '/');
}
private function getUserInfo() {
if ($this->userInfo === null) {
$this->currentSession = $this->newJIRAFuture('rest/auth/1/session', 'GET')
->resolveJSON();
// The session call gives us the username, but not the user key or other
// information. Make a second call to get additional information.
$params = array(
'username' => $this->currentSession['name'],
);
$this->userInfo = $this->newJIRAFuture('rest/api/2/user', 'GET', $params)
->resolveJSON();
}
return $this->userInfo;
}
public static function newJIRAKeypair() {
$config = array(
'digest_alg' => 'sha512',
'private_key_bits' => 4096,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
);
$res = openssl_pkey_new($config);
if (!$res) {
throw new Exception(pht('%s failed!', 'openssl_pkey_new()'));
}
$private_key = null;
$ok = openssl_pkey_export($res, $private_key);
if (!$ok) {
throw new Exception(pht('%s failed!', 'openssl_pkey_export()'));
}
$public_key = openssl_pkey_get_details($res);
if (!$ok || empty($public_key['key'])) {
throw new Exception(pht('%s failed!', 'openssl_pkey_get_details()'));
}
$public_key = $public_key['key'];
return array($public_key, $private_key);
}
/**
* JIRA indicates that the user has clicked the "Deny" button by passing a
* well known `oauth_verifier` value ("denied"), which we check for here.
*/
protected function willFinishOAuthHandshake() {
$jira_magic_word = 'denied';
if ($this->getVerifier() == $jira_magic_word) {
throw new PhutilAuthUserAbortedException();
}
}
public function newJIRAFuture($path, $method, $params = array()) {
if ($method == 'GET') {
$uri_params = $params;
$body_params = array();
} else {
// For other types of requests, JIRA expects the request body to be
// JSON encoded.
$uri_params = array();
$body_params = phutil_json_encode($params);
}
$uri = new PhutilURI($this->getJIRAURI($path), $uri_params);
// JIRA returns a 415 error if we don't provide a Content-Type header.
return $this->newOAuth1Future($uri, $body_params)
->setMethod($method)
->addHeader('Content-Type', 'application/json');
}
}

View file

@ -0,0 +1,505 @@
<?php
/**
* Retrieve identify information from LDAP accounts.
*/
final class PhutilLDAPAuthAdapter extends PhutilAuthAdapter {
private $hostname;
private $port = 389;
private $baseDistinguishedName;
private $searchAttributes = array();
private $usernameAttribute;
private $realNameAttributes = array();
private $ldapVersion = 3;
private $ldapReferrals;
private $ldapStartTLS;
private $anonymousUsername;
private $anonymousPassword;
private $activeDirectoryDomain;
private $alwaysSearch;
private $loginUsername;
private $loginPassword;
private $ldapUserData;
private $ldapConnection;
public function getAdapterType() {
return 'ldap';
}
public function setHostname($host) {
$this->hostname = $host;
return $this;
}
public function setPort($port) {
$this->port = $port;
return $this;
}
public function getAdapterDomain() {
return 'self';
}
public function setBaseDistinguishedName($base_distinguished_name) {
$this->baseDistinguishedName = $base_distinguished_name;
return $this;
}
public function setSearchAttributes(array $search_attributes) {
$this->searchAttributes = $search_attributes;
return $this;
}
public function setUsernameAttribute($username_attribute) {
$this->usernameAttribute = $username_attribute;
return $this;
}
public function setRealNameAttributes(array $attributes) {
$this->realNameAttributes = $attributes;
return $this;
}
public function setLDAPVersion($ldap_version) {
$this->ldapVersion = $ldap_version;
return $this;
}
public function setLDAPReferrals($ldap_referrals) {
$this->ldapReferrals = $ldap_referrals;
return $this;
}
public function setLDAPStartTLS($ldap_start_tls) {
$this->ldapStartTLS = $ldap_start_tls;
return $this;
}
public function setAnonymousUsername($anonymous_username) {
$this->anonymousUsername = $anonymous_username;
return $this;
}
public function setAnonymousPassword(
PhutilOpaqueEnvelope $anonymous_password) {
$this->anonymousPassword = $anonymous_password;
return $this;
}
public function setLoginUsername($login_username) {
$this->loginUsername = $login_username;
return $this;
}
public function setLoginPassword(PhutilOpaqueEnvelope $login_password) {
$this->loginPassword = $login_password;
return $this;
}
public function setActiveDirectoryDomain($domain) {
$this->activeDirectoryDomain = $domain;
return $this;
}
public function setAlwaysSearch($always_search) {
$this->alwaysSearch = $always_search;
return $this;
}
public function getAccountID() {
return $this->readLDAPRecordAccountID($this->getLDAPUserData());
}
public function getAccountName() {
return $this->readLDAPRecordAccountName($this->getLDAPUserData());
}
public function getAccountRealName() {
return $this->readLDAPRecordRealName($this->getLDAPUserData());
}
public function getAccountEmail() {
return $this->readLDAPRecordEmail($this->getLDAPUserData());
}
public function readLDAPRecordAccountID(array $record) {
$key = $this->usernameAttribute;
if (!strlen($key)) {
$key = head($this->searchAttributes);
}
return $this->readLDAPData($record, $key);
}
public function readLDAPRecordAccountName(array $record) {
return $this->readLDAPRecordAccountID($record);
}
public function readLDAPRecordRealName(array $record) {
$parts = array();
foreach ($this->realNameAttributes as $attribute) {
$parts[] = $this->readLDAPData($record, $attribute);
}
$parts = array_filter($parts);
if ($parts) {
return implode(' ', $parts);
}
return null;
}
public function readLDAPRecordEmail(array $record) {
return $this->readLDAPData($record, 'mail');
}
private function getLDAPUserData() {
if ($this->ldapUserData === null) {
$this->ldapUserData = $this->loadLDAPUserData();
}
return $this->ldapUserData;
}
private function readLDAPData(array $data, $key, $default = null) {
$list = idx($data, $key);
if ($list === null) {
// At least in some cases (and maybe in all cases) the results from
// ldap_search() are keyed in lowercase. If we missed on the first
// try, retry with a lowercase key.
$list = idx($data, phutil_utf8_strtolower($key));
}
// NOTE: In most cases, the property is an array, like:
//
// array(
// 'count' => 1,
// 0 => 'actual-value-we-want',
// )
//
// However, in at least the case of 'dn', the property is a bare string.
if (is_scalar($list) && strlen($list)) {
return $list;
} else if (is_array($list)) {
return $list[0];
} else {
return $default;
}
}
private function formatLDAPAttributeSearch($attribute, $login_user) {
// If the attribute contains the literal token "${login}", treat it as a
// query and substitute the user's login name for the token.
if (strpos($attribute, '${login}') !== false) {
$escaped_user = ldap_sprintf('%S', $login_user);
$attribute = str_replace('${login}', $escaped_user, $attribute);
return $attribute;
}
// Otherwise, treat it as a simple attribute search.
return ldap_sprintf(
'%Q=%S',
$attribute,
$login_user);
}
private function loadLDAPUserData() {
$conn = $this->establishConnection();
$login_user = $this->loginUsername;
$login_pass = $this->loginPassword;
if ($this->shouldBindWithoutIdentity()) {
$distinguished_name = null;
$search_query = null;
foreach ($this->searchAttributes as $attribute) {
$search_query = $this->formatLDAPAttributeSearch(
$attribute,
$login_user);
$record = $this->searchLDAPForRecord($search_query);
if ($record) {
$distinguished_name = $this->readLDAPData($record, 'dn');
break;
}
}
if ($distinguished_name === null) {
throw new PhutilAuthCredentialException();
}
} else {
$search_query = $this->formatLDAPAttributeSearch(
head($this->searchAttributes),
$login_user);
if ($this->activeDirectoryDomain) {
$distinguished_name = ldap_sprintf(
'%s@%Q',
$login_user,
$this->activeDirectoryDomain);
} else {
$distinguished_name = ldap_sprintf(
'%Q,%Q',
$search_query,
$this->baseDistinguishedName);
}
}
$this->bindLDAP($conn, $distinguished_name, $login_pass);
$result = $this->searchLDAPForRecord($search_query);
if (!$result) {
// This is unusual (since the bind succeeded) but we've seen it at least
// once in the wild, where the anonymous user is allowed to search but
// the credentialed user is not.
// If we don't have anonymous credentials, raise an explicit exception
// here since we'll fail a typehint if we don't return an array anyway
// and this is a more useful error.
// If we do have anonymous credentials, we'll rebind and try the search
// again below. Doing this automatically means things work correctly more
// often without requiring additional configuration.
if (!$this->shouldBindWithoutIdentity()) {
// No anonymous credentials, so we just fail here.
throw new Exception(
pht(
'LDAP: Failed to retrieve record for user "%s" when searching. '.
'Credentialed users may not be able to search your LDAP server. '.
'Try configuring anonymous credentials or fully anonymous binds.',
$login_user));
} else {
// Rebind as anonymous and try the search again.
$user = $this->anonymousUsername;
$pass = $this->anonymousPassword;
$this->bindLDAP($conn, $user, $pass);
$result = $this->searchLDAPForRecord($search_query);
if (!$result) {
throw new Exception(
pht(
'LDAP: Failed to retrieve record for user "%s" when searching '.
'with both user and anonymous credentials.',
$login_user));
}
}
}
return $result;
}
private function establishConnection() {
if (!$this->ldapConnection) {
$host = $this->hostname;
$port = $this->port;
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'connect',
'host' => $host,
'port' => $this->port,
));
$conn = @ldap_connect($host, $this->port);
$profiler->endServiceCall(
$call_id,
array(
'ok' => (bool)$conn,
));
if (!$conn) {
throw new Exception(
pht('Unable to connect to LDAP server (%s:%d).', $host, $port));
}
$options = array(
LDAP_OPT_PROTOCOL_VERSION => (int)$this->ldapVersion,
LDAP_OPT_REFERRALS => (int)$this->ldapReferrals,
);
foreach ($options as $name => $value) {
$ok = @ldap_set_option($conn, $name, $value);
if (!$ok) {
$this->raiseConnectionException(
$conn,
pht(
"Unable to set LDAP option '%s' to value '%s'!",
$name,
$value));
}
}
if ($this->ldapStartTLS) {
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'start-tls',
));
// NOTE: This boils down to a function call to ldap_start_tls_s() in
// C, which is a service call.
$ok = @ldap_start_tls($conn);
$profiler->endServiceCall(
$call_id,
array());
if (!$ok) {
$this->raiseConnectionException(
$conn,
pht('Unable to start TLS connection when connecting to LDAP.'));
}
}
if ($this->shouldBindWithoutIdentity()) {
$user = $this->anonymousUsername;
$pass = $this->anonymousPassword;
$this->bindLDAP($conn, $user, $pass);
}
$this->ldapConnection = $conn;
}
return $this->ldapConnection;
}
private function searchLDAPForRecord($dn) {
$conn = $this->establishConnection();
$results = $this->searchLDAP('%Q', $dn);
if (!$results) {
return null;
}
if (count($results) > 1) {
throw new Exception(
pht(
'LDAP record query returned more than one result. The query must '.
'uniquely identify a record.'));
}
return head($results);
}
public function searchLDAP($pattern /* ... */) {
$args = func_get_args();
$query = call_user_func_array('ldap_sprintf', $args);
$conn = $this->establishConnection();
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'search',
'dn' => $this->baseDistinguishedName,
'query' => $query,
));
$result = @ldap_search($conn, $this->baseDistinguishedName, $query);
$profiler->endServiceCall($call_id, array());
if (!$result) {
$this->raiseConnectionException(
$conn,
pht('LDAP search failed.'));
}
$entries = @ldap_get_entries($conn, $result);
if (!$entries) {
$this->raiseConnectionException(
$conn,
pht('Failed to get LDAP entries from search result.'));
}
$results = array();
for ($ii = 0; $ii < $entries['count']; $ii++) {
$results[] = $entries[$ii];
}
return $results;
}
private function raiseConnectionException($conn, $message) {
$errno = @ldap_errno($conn);
$error = @ldap_error($conn);
// This is `LDAP_INVALID_CREDENTIALS`.
if ($errno == 49) {
throw new PhutilAuthCredentialException();
}
if ($errno || $error) {
$full_message = pht(
"LDAP Exception: %s\nLDAP Error #%d: %s",
$message,
$errno,
$error);
} else {
$full_message = pht(
'LDAP Exception: %s',
$message);
}
throw new Exception($full_message);
}
private function bindLDAP($conn, $user, PhutilOpaqueEnvelope $pass) {
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'bind',
'user' => $user,
));
// NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep
// it quiet.
if (strlen($user)) {
$ok = @ldap_bind($conn, $user, $pass->openEnvelope());
} else {
$ok = @ldap_bind($conn);
}
$profiler->endServiceCall($call_id, array());
if (!$ok) {
if (strlen($user)) {
$this->raiseConnectionException(
$conn,
pht('Failed to bind to LDAP server (as user "%s").', $user));
} else {
$this->raiseConnectionException(
$conn,
pht('Failed to bind to LDAP server (without username).'));
}
}
}
/**
* Determine if this adapter should attempt to bind to the LDAP server
* without a user identity.
*
* Generally, we can bind directly if we have a username/password, or if the
* "Always Search" flag is set, indicating that the empty username and
* password are sufficient.
*
* @return bool True if the adapter should perform binds without identity.
*/
private function shouldBindWithoutIdentity() {
return $this->alwaysSearch || strlen($this->anonymousUsername);
}
}

View file

@ -0,0 +1,211 @@
<?php
/**
* Abstract adapter for OAuth1 providers.
*/
abstract class PhutilOAuth1AuthAdapter extends PhutilAuthAdapter {
private $consumerKey;
private $consumerSecret;
private $token;
private $tokenSecret;
private $verifier;
private $handshakeData;
private $callbackURI;
private $privateKey;
public function setPrivateKey(PhutilOpaqueEnvelope $private_key) {
$this->privateKey = $private_key;
return $this;
}
public function getPrivateKey() {
return $this->privateKey;
}
public function setCallbackURI($callback_uri) {
$this->callbackURI = $callback_uri;
return $this;
}
public function getCallbackURI() {
return $this->callbackURI;
}
public function setVerifier($verifier) {
$this->verifier = $verifier;
return $this;
}
public function getVerifier() {
return $this->verifier;
}
public function setConsumerSecret(PhutilOpaqueEnvelope $consumer_secret) {
$this->consumerSecret = $consumer_secret;
return $this;
}
public function getConsumerSecret() {
return $this->consumerSecret;
}
public function setConsumerKey($consumer_key) {
$this->consumerKey = $consumer_key;
return $this;
}
public function getConsumerKey() {
return $this->consumerKey;
}
public function setTokenSecret($token_secret) {
$this->tokenSecret = $token_secret;
return $this;
}
public function getTokenSecret() {
return $this->tokenSecret;
}
public function setToken($token) {
$this->token = $token;
return $this;
}
public function getToken() {
return $this->token;
}
protected function getHandshakeData() {
if ($this->handshakeData === null) {
$this->finishOAuthHandshake();
}
return $this->handshakeData;
}
abstract protected function getRequestTokenURI();
abstract protected function getAuthorizeTokenURI();
abstract protected function getValidateTokenURI();
protected function getSignatureMethod() {
return 'HMAC-SHA1';
}
public function getContentSecurityPolicyFormActions() {
return array(
$this->getAuthorizeTokenURI(),
);
}
protected function newOAuth1Future($uri, $data = array()) {
$future = id(new PhutilOAuth1Future($uri, $data))
->setMethod('POST')
->setSignatureMethod($this->getSignatureMethod());
$consumer_key = $this->getConsumerKey();
if (strlen($consumer_key)) {
$future->setConsumerKey($consumer_key);
} else {
throw new Exception(
pht(
'%s is required!',
'setConsumerKey()'));
}
$consumer_secret = $this->getConsumerSecret();
if ($consumer_secret) {
$future->setConsumerSecret($consumer_secret);
}
if (strlen($this->getToken())) {
$future->setToken($this->getToken());
}
if (strlen($this->getTokenSecret())) {
$future->setTokenSecret($this->getTokenSecret());
}
if ($this->getPrivateKey()) {
$future->setPrivateKey($this->getPrivateKey());
}
return $future;
}
public function getClientRedirectURI() {
$request_token_uri = $this->getRequestTokenURI();
$future = $this->newOAuth1Future($request_token_uri);
if (strlen($this->getCallbackURI())) {
$future->setCallbackURI($this->getCallbackURI());
}
list($body) = $future->resolvex();
$data = id(new PhutilQueryStringParser())->parseQueryString($body);
// NOTE: Per the spec, this value MUST be the string 'true'.
$confirmed = idx($data, 'oauth_callback_confirmed');
if ($confirmed !== 'true') {
throw new Exception(
pht("Expected '%s' to be '%s'!", 'oauth_callback_confirmed', 'true'));
}
$this->readTokenAndTokenSecret($data);
$authorize_token_uri = new PhutilURI($this->getAuthorizeTokenURI());
$authorize_token_uri->replaceQueryParam('oauth_token', $this->getToken());
return (string)$authorize_token_uri;
}
protected function finishOAuthHandshake() {
$this->willFinishOAuthHandshake();
if (!$this->getToken()) {
throw new Exception(pht('Expected token to finish OAuth handshake!'));
}
if (!$this->getVerifier()) {
throw new Exception(pht('Expected verifier to finish OAuth handshake!'));
}
$validate_uri = $this->getValidateTokenURI();
$params = array(
'oauth_verifier' => $this->getVerifier(),
);
list($body) = $this->newOAuth1Future($validate_uri, $params)->resolvex();
$data = id(new PhutilQueryStringParser())->parseQueryString($body);
$this->readTokenAndTokenSecret($data);
$this->handshakeData = $data;
}
private function readTokenAndTokenSecret(array $data) {
$token = idx($data, 'oauth_token');
if (!$token) {
throw new Exception(pht("Expected '%s' in response!", 'oauth_token'));
}
$token_secret = idx($data, 'oauth_token_secret');
if (!$token_secret) {
throw new Exception(
pht("Expected '%s' in response!", 'oauth_token_secret'));
}
$this->setToken($token);
$this->setTokenSecret($token_secret);
return $this;
}
/**
* Hook that allows subclasses to take actions before the OAuth handshake
* is completed.
*/
protected function willFinishOAuthHandshake() {
return;
}
}

View file

@ -0,0 +1,228 @@
<?php
/**
* Abstract adapter for OAuth2 providers.
*/
abstract class PhutilOAuthAuthAdapter extends PhutilAuthAdapter {
private $clientID;
private $clientSecret;
private $redirectURI;
private $scope;
private $state;
private $code;
private $accessTokenData;
private $oauthAccountData;
abstract protected function getAuthenticateBaseURI();
abstract protected function getTokenBaseURI();
abstract protected function loadOAuthAccountData();
public function getAuthenticateURI() {
$params = array(
'client_id' => $this->getClientID(),
'scope' => $this->getScope(),
'redirect_uri' => $this->getRedirectURI(),
'state' => $this->getState(),
) + $this->getExtraAuthenticateParameters();
$uri = new PhutilURI($this->getAuthenticateBaseURI(), $params);
return phutil_string_cast($uri);
}
public function getAdapterType() {
$this_class = get_class($this);
$type_name = str_replace('PhutilAuthAdapterOAuth', '', $this_class);
return strtolower($type_name);
}
public function setState($state) {
$this->state = $state;
return $this;
}
public function getState() {
return $this->state;
}
public function setCode($code) {
$this->code = $code;
return $this;
}
public function getCode() {
return $this->code;
}
public function setRedirectURI($redirect_uri) {
$this->redirectURI = $redirect_uri;
return $this;
}
public function getRedirectURI() {
return $this->redirectURI;
}
public function getExtraAuthenticateParameters() {
return array();
}
public function getExtraTokenParameters() {
return array();
}
public function getExtraRefreshParameters() {
return array();
}
public function setScope($scope) {
$this->scope = $scope;
return $this;
}
public function getScope() {
return $this->scope;
}
public function setClientSecret(PhutilOpaqueEnvelope $client_secret) {
$this->clientSecret = $client_secret;
return $this;
}
public function getClientSecret() {
return $this->clientSecret;
}
public function setClientID($client_id) {
$this->clientID = $client_id;
return $this;
}
public function getClientID() {
return $this->clientID;
}
public function getAccessToken() {
return $this->getAccessTokenData('access_token');
}
public function getAccessTokenExpires() {
return $this->getAccessTokenData('expires_epoch');
}
public function getRefreshToken() {
return $this->getAccessTokenData('refresh_token');
}
protected function getAccessTokenData($key, $default = null) {
if ($this->accessTokenData === null) {
$this->accessTokenData = $this->loadAccessTokenData();
}
return idx($this->accessTokenData, $key, $default);
}
public function supportsTokenRefresh() {
return false;
}
public function refreshAccessToken($refresh_token) {
$this->accessTokenData = $this->loadRefreshTokenData($refresh_token);
return $this;
}
protected function loadRefreshTokenData($refresh_token) {
$params = array(
'refresh_token' => $refresh_token,
) + $this->getExtraRefreshParameters();
// NOTE: Make sure we return the refresh_token so that subsequent
// calls to getRefreshToken() return it; providers normally do not echo
// it back for token refresh requests.
return $this->makeTokenRequest($params) + array(
'refresh_token' => $refresh_token,
);
}
protected function loadAccessTokenData() {
$code = $this->getCode();
if (!$code) {
throw new PhutilInvalidStateException('setCode');
}
$params = array(
'code' => $this->getCode(),
) + $this->getExtraTokenParameters();
return $this->makeTokenRequest($params);
}
private function makeTokenRequest(array $params) {
$uri = $this->getTokenBaseURI();
$query_data = array(
'client_id' => $this->getClientID(),
'client_secret' => $this->getClientSecret()->openEnvelope(),
'redirect_uri' => $this->getRedirectURI(),
) + $params;
$future = new HTTPSFuture($uri, $query_data);
$future->setMethod('POST');
list($body) = $future->resolvex();
$data = $this->readAccessTokenResponse($body);
if (isset($data['expires_in'])) {
$data['expires_epoch'] = $data['expires_in'];
} else if (isset($data['expires'])) {
$data['expires_epoch'] = $data['expires'];
}
// If we got some "expires" value back, interpret it as an epoch timestamp
// if it's after the year 2010 and as a relative number of seconds
// otherwise.
if (isset($data['expires_epoch'])) {
if ($data['expires_epoch'] < (60 * 60 * 24 * 365 * 40)) {
$data['expires_epoch'] += time();
}
}
if (isset($data['error'])) {
throw new Exception(pht('Access token error: %s', $data['error']));
}
return $data;
}
protected function readAccessTokenResponse($body) {
// NOTE: Most providers either return JSON or HTTP query strings, so try
// both mechanisms. If your provider does something else, override this
// method.
$data = json_decode($body, true);
if (!is_array($data)) {
$data = array();
parse_str($body, $data);
}
if (empty($data['access_token']) &&
empty($data['error'])) {
throw new Exception(
pht('Failed to decode OAuth access token response: %s', $body));
}
return $data;
}
protected function getOAuthAccountData($key, $default = null) {
if ($this->oauthAccountData === null) {
$this->oauthAccountData = $this->loadOAuthAccountData();
}
return idx($this->oauthAccountData, $key, $default);
}
}

View file

@ -0,0 +1,102 @@
<?php
/**
* Authentication adapter for Phabricator OAuth2.
*/
final class PhutilPhabricatorAuthAdapter extends PhutilOAuthAuthAdapter {
private $phabricatorBaseURI;
private $adapterDomain;
public function setPhabricatorBaseURI($uri) {
$this->phabricatorBaseURI = $uri;
return $this;
}
public function getPhabricatorBaseURI() {
return $this->phabricatorBaseURI;
}
public function getAdapterDomain() {
return $this->adapterDomain;
}
public function setAdapterDomain($domain) {
$this->adapterDomain = $domain;
return $this;
}
public function getAdapterType() {
return 'phabricator';
}
public function getAccountID() {
return $this->getOAuthAccountData('phid');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('primaryEmail');
}
public function getAccountName() {
return $this->getOAuthAccountData('userName');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('image');
}
public function getAccountURI() {
return $this->getOAuthAccountData('uri');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('realName');
}
protected function getAuthenticateBaseURI() {
return $this->getPhabricatorURI('oauthserver/auth/');
}
protected function getTokenBaseURI() {
return $this->getPhabricatorURI('oauthserver/token/');
}
public function getScope() {
return '';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = id(new PhutilURI($this->getPhabricatorURI('api/user.whoami')))
->replaceQueryParam('access_token', $this->getAccessToken());
list($body) = id(new HTTPSFuture($uri))->resolvex();
try {
$data = phutil_json_decode($body);
return $data['result'];
} catch (PhutilJSONParserException $ex) {
throw new Exception(
pht(
'Expected valid JSON response from Phabricator %s request.',
'user.whoami'),
$ex);
}
}
private function getPhabricatorURI($path) {
return rtrim($this->phabricatorBaseURI, '/').'/'.ltrim($path, '/');
}
}

View file

@ -0,0 +1,61 @@
<?php
/**
* Authentication adapter for Slack OAuth2.
*/
final class PhutilSlackAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'Slack';
}
public function getAdapterDomain() {
return 'slack.com';
}
public function getAccountID() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'id');
}
public function getAccountEmail() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'email');
}
public function getAccountImageURI() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'image_512');
}
public function getAccountRealName() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'name');
}
protected function getAuthenticateBaseURI() {
return 'https://slack.com/oauth/authorize';
}
protected function getTokenBaseURI() {
return 'https://slack.com/api/oauth.access';
}
public function getScope() {
return 'identity.basic,identity.team,identity.avatar';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
protected function loadOAuthAccountData() {
return id(new PhutilSlackFuture())
->setAccessToken($this->getAccessToken())
->setRawSlackQuery('users.identity')
->resolve();
}
}

View file

@ -0,0 +1,76 @@
<?php
/**
* Authentication adapter for Twitch.tv OAuth2.
*/
final class PhutilTwitchAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'twitch';
}
public function getAdapterDomain() {
return 'twitch.tv';
}
public function getAccountID() {
return $this->getOAuthAccountData('_id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('name');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('logo');
}
public function getAccountURI() {
$name = $this->getAccountName();
if ($name) {
return 'http://www.twitch.tv/'.$name;
}
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('display_name');
}
protected function getAuthenticateBaseURI() {
return 'https://api.twitch.tv/kraken/oauth2/authorize';
}
protected function getTokenBaseURI() {
return 'https://api.twitch.tv/kraken/oauth2/token';
}
public function getScope() {
return 'user_read';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
return id(new PhutilTwitchFuture())
->setClientID($this->getClientID())
->setAccessToken($this->getAccessToken())
->setRawTwitchQuery('user')
->resolve();
}
}

View file

@ -0,0 +1,75 @@
<?php
/**
* Authentication adapter for Twitter OAuth1.
*/
final class PhutilTwitterAuthAdapter extends PhutilOAuth1AuthAdapter {
private $userInfo;
public function getAccountID() {
return idx($this->getHandshakeData(), 'user_id');
}
public function getAccountName() {
return idx($this->getHandshakeData(), 'screen_name');
}
public function getAccountURI() {
$name = $this->getAccountName();
if (strlen($name)) {
return 'https://twitter.com/'.$name;
}
return null;
}
public function getAccountImageURI() {
$info = $this->getUserInfo();
return idx($info, 'profile_image_url');
}
public function getAccountRealName() {
$info = $this->getUserInfo();
return idx($info, 'name');
}
public function getAdapterType() {
return 'twitter';
}
public function getAdapterDomain() {
return 'twitter.com';
}
protected function getRequestTokenURI() {
return 'https://api.twitter.com/oauth/request_token';
}
protected function getAuthorizeTokenURI() {
return 'https://api.twitter.com/oauth/authorize';
}
protected function getValidateTokenURI() {
return 'https://api.twitter.com/oauth/access_token';
}
private function getUserInfo() {
if ($this->userInfo === null) {
$params = array(
'user_id' => $this->getAccountID(),
);
$uri = new PhutilURI(
'https://api.twitter.com/1.1/users/show.json',
$params);
$data = $this->newOAuth1Future($uri)
->setMethod('GET')
->resolveJSON();
$this->userInfo = $data;
}
return $this->userInfo;
}
}

View file

@ -0,0 +1,73 @@
<?php
/**
* Authentication adapter for WordPress.com OAuth2.
*/
final class PhutilWordPressAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'wordpress';
}
public function getAdapterDomain() {
return 'wordpress.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('ID');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('username');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('avatar_URL');
}
public function getAccountURI() {
return $this->getOAuthAccountData('profile_URL');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('display_name');
}
protected function getAuthenticateBaseURI() {
return 'https://public-api.wordpress.com/oauth2/authorize';
}
protected function getTokenBaseURI() {
return 'https://public-api.wordpress.com/oauth2/token';
}
public function getScope() {
return 'user_read';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
'blog_id' => 0,
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
return id(new PhutilWordPressFuture())
->setClientID($this->getClientID())
->setAccessToken($this->getAccessToken())
->setRawWordPressQuery('/me/')
->resolve();
}
}

View file

@ -0,0 +1,6 @@
<?php
/**
* Authentication is not configured correctly.
*/
final class PhutilAuthConfigurationException extends PhutilAuthException {}

View file

@ -0,0 +1,6 @@
<?php
/**
* The user provided invalid credentials.
*/
final class PhutilAuthCredentialException extends PhutilAuthException {}

View file

@ -0,0 +1,7 @@
<?php
/**
* Abstract exception class for errors encountered during authentication
* workflows.
*/
abstract class PhutilAuthException extends Exception {}

View file

@ -0,0 +1,14 @@
<?php
/**
* The user aborted the authentication workflow, by clicking "Cancel" or "Deny"
* or taking some similar action.
*
* For example, in OAuth/OAuth2 workflows, the authentication provider
* generally presents the user with a confirmation dialog with two options,
* "Approve" and "Deny".
*
* If an adapter detects that the user has explicitly bailed out of the
* workflow, it should throw this exception.
*/
final class PhutilAuthUserAbortedException extends PhutilAuthException {}

View file

@ -0,0 +1,287 @@
<?php
final class PhutilCalendarAbsoluteDateTime
extends PhutilCalendarDateTime {
private $year;
private $month;
private $day;
private $hour = 0;
private $minute = 0;
private $second = 0;
private $timezone;
public static function newFromISO8601($value, $timezone = 'UTC') {
$pattern =
'/^'.
'(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'.
'(?:'.
'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>Z)?'.
')?'.
'\z/';
$matches = null;
$ok = preg_match($pattern, $value, $matches);
if (!$ok) {
throw new Exception(
pht(
'Expected ISO8601 datetime in the format "19990105T112233Z", '.
'found "%s".',
$value));
}
if (isset($matches['z'])) {
if ($timezone != 'UTC') {
throw new Exception(
pht(
'ISO8601 date ends in "Z" indicating UTC, but a timezone other '.
'than UTC ("%s") was specified.',
$timezone));
}
}
$datetime = id(new self())
->setYear((int)$matches['y'])
->setMonth((int)$matches['m'])
->setDay((int)$matches['d'])
->setTimezone($timezone);
if (isset($matches['h'])) {
$datetime
->setHour((int)$matches['h'])
->setMinute((int)$matches['i'])
->setSecond((int)$matches['s']);
} else {
$datetime
->setIsAllDay(true);
}
return $datetime;
}
public static function newFromEpoch($epoch, $timezone = 'UTC') {
$date = new DateTime('@'.$epoch);
$zone = new DateTimeZone($timezone);
$date->setTimezone($zone);
return id(new self())
->setYear((int)$date->format('Y'))
->setMonth((int)$date->format('m'))
->setDay((int)$date->format('d'))
->setHour((int)$date->format('H'))
->setMinute((int)$date->format('i'))
->setSecond((int)$date->format('s'))
->setTimezone($timezone);
}
public static function newFromDictionary(array $dict) {
static $keys;
if ($keys === null) {
$keys = array_fuse(
array(
'kind',
'year',
'month',
'day',
'hour',
'minute',
'second',
'timezone',
'isAllDay',
));
}
foreach ($dict as $key => $value) {
if (!isset($keys[$key])) {
throw new Exception(
pht(
'Unexpected key "%s" in datetime dictionary, expected keys: %s.',
$key,
implode(', ', array_keys($keys))));
}
}
if (idx($dict, 'kind') !== 'absolute') {
throw new Exception(
pht(
'Expected key "%s" with value "%s" in datetime dictionary.',
'kind',
'absolute'));
}
if (!isset($dict['year'])) {
throw new Exception(
pht(
'Expected key "%s" in datetime dictionary.',
'year'));
}
$datetime = id(new self())
->setYear(idx($dict, 'year'))
->setMonth(idx($dict, 'month', 1))
->setDay(idx($dict, 'day', 1))
->setHour(idx($dict, 'hour', 0))
->setMinute(idx($dict, 'minute', 0))
->setSecond(idx($dict, 'second', 0))
->setTimezone(idx($dict, 'timezone'))
->setIsAllDay((bool)idx($dict, 'isAllDay', false));
return $datetime;
}
public function newRelativeDateTime($duration) {
if (is_string($duration)) {
$duration = PhutilCalendarDuration::newFromISO8601($duration);
}
if (!($duration instanceof PhutilCalendarDuration)) {
throw new Exception(
pht(
'Expected "PhutilCalendarDuration" object or ISO8601 duration '.
'string.'));
}
return id(new PhutilCalendarRelativeDateTime())
->setOrigin($this)
->setDuration($duration);
}
public function toDictionary() {
return array(
'kind' => 'absolute',
'year' => (int)$this->getYear(),
'month' => (int)$this->getMonth(),
'day' => (int)$this->getDay(),
'hour' => (int)$this->getHour(),
'minute' => (int)$this->getMinute(),
'second' => (int)$this->getSecond(),
'timezone' => $this->getTimezone(),
'isAllDay' => (bool)$this->getIsAllDay(),
);
}
public function setYear($year) {
$this->year = $year;
return $this;
}
public function getYear() {
return $this->year;
}
public function setMonth($month) {
$this->month = $month;
return $this;
}
public function getMonth() {
return $this->month;
}
public function setDay($day) {
$this->day = $day;
return $this;
}
public function getDay() {
return $this->day;
}
public function setHour($hour) {
$this->hour = $hour;
return $this;
}
public function getHour() {
return $this->hour;
}
public function setMinute($minute) {
$this->minute = $minute;
return $this;
}
public function getMinute() {
return $this->minute;
}
public function setSecond($second) {
$this->second = $second;
return $this;
}
public function getSecond() {
return $this->second;
}
public function setTimezone($timezone) {
$this->timezone = $timezone;
return $this;
}
public function getTimezone() {
return $this->timezone;
}
private function getEffectiveTimezone() {
$date_timezone = $this->getTimezone();
$viewer_timezone = $this->getViewerTimezone();
// Because all-day events are always "floating", the effective timezone
// is the viewer timezone if it is available. Otherwise, we'll return a
// DateTime object with the correct values, but it will be incorrectly
// adjusted forward or backward to the viewer's zone later.
$zones = array();
if ($this->getIsAllDay()) {
$zones[] = $viewer_timezone;
$zones[] = $date_timezone;
} else {
$zones[] = $date_timezone;
$zones[] = $viewer_timezone;
}
$zones = array_filter($zones);
if (!$zones) {
throw new Exception(
pht(
'Datetime has no timezone or viewer timezone.'));
}
return head($zones);
}
public function newPHPDateTimeZone() {
$zone = $this->getEffectiveTimezone();
return new DateTimeZone($zone);
}
public function newPHPDateTime() {
$zone = $this->newPHPDateTimeZone();
$y = $this->getYear();
$m = $this->getMonth();
$d = $this->getDay();
if ($this->getIsAllDay()) {
$h = 0;
$i = 0;
$s = 0;
} else {
$h = $this->getHour();
$i = $this->getMinute();
$s = $this->getSecond();
}
$format = sprintf('%04d-%02d-%02d %02d:%02d:%02d', $y, $m, $d, $h, $i, $s);
return new DateTime($format, $zone);
}
public function newAbsoluteDateTime() {
return clone $this;
}
}

View file

@ -0,0 +1,30 @@
<?php
abstract class PhutilCalendarContainerNode
extends PhutilCalendarNode {
private $children = array();
final public function getChildren() {
return $this->children;
}
final public function getChildrenOfType($type) {
$result = array();
foreach ($this->getChildren() as $key => $child) {
if ($child->getNodeType() != $type) {
continue;
}
$result[$key] = $child;
}
return $result;
}
final public function appendChild(PhutilCalendarNode $node) {
$this->children[] = $node;
return $this;
}
}

View file

@ -0,0 +1,54 @@
<?php
abstract class PhutilCalendarDateTime
extends Phobject {
private $viewerTimezone;
private $isAllDay = false;
public function setViewerTimezone($viewer_timezone) {
$this->viewerTimezone = $viewer_timezone;
return $this;
}
public function getViewerTimezone() {
return $this->viewerTimezone;
}
public function setIsAllDay($is_all_day) {
$this->isAllDay = $is_all_day;
return $this;
}
public function getIsAllDay() {
return $this->isAllDay;
}
public function getEpoch() {
$datetime = $this->newPHPDateTime();
return (int)$datetime->format('U');
}
public function getISO8601() {
$datetime = $this->newPHPDateTime();
if ($this->getIsAllDay()) {
return $datetime->format('Ymd');
} else if ($this->getTimezone()) {
// With a timezone, the event occurs at a specific second universally.
// We return the UTC representation of that point in time.
$datetime->setTimezone(new DateTimeZone('UTC'));
return $datetime->format('Ymd\\THis\\Z');
} else {
// With no timezone, events are "floating" and occur at local time.
// We return a representation without the "Z".
return $datetime->format('Ymd\\THis');
}
}
abstract public function newPHPDateTimeZone();
abstract public function newPHPDateTime();
abstract public function newAbsoluteDateTime();
abstract public function getTimezone();
}

View file

@ -0,0 +1,12 @@
<?php
final class PhutilCalendarDocumentNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'document';
public function getEvents() {
return $this->getChildrenOfType(PhutilCalendarEventNode::NODETYPE);
}
}

View file

@ -0,0 +1,181 @@
<?php
final class PhutilCalendarDuration extends Phobject {
private $isNegative = false;
private $weeks = 0;
private $days = 0;
private $hours = 0;
private $minutes = 0;
private $seconds = 0;
public static function newFromDictionary(array $dict) {
static $keys;
if ($keys === null) {
$keys = array_fuse(
array(
'isNegative',
'weeks',
'days',
'hours',
'minutes',
'seconds',
));
}
foreach ($dict as $key => $value) {
if (!isset($keys[$key])) {
throw new Exception(
pht(
'Unexpected key "%s" in duration dictionary, expected keys: %s.',
$key,
implode(', ', array_keys($keys))));
}
}
$duration = id(new self())
->setIsNegative(idx($dict, 'isNegative', false))
->setWeeks(idx($dict, 'weeks', 0))
->setDays(idx($dict, 'days', 0))
->setHours(idx($dict, 'hours', 0))
->setMinutes(idx($dict, 'minutes', 0))
->setSeconds(idx($dict, 'seconds', 0));
return $duration;
}
public function toDictionary() {
return array(
'isNegative' => $this->getIsNegative(),
'weeks' => $this->getWeeks(),
'days' => $this->getDays(),
'hours' => $this->getHours(),
'minutes' => $this->getMinutes(),
'seconds' => $this->getSeconds(),
);
}
public static function newFromISO8601($value) {
$pattern =
'/^'.
'(?P<sign>[+-])?'.
'P'.
'(?:'.
'(?P<W>\d+)W'.
'|'.
'(?:(?:(?P<D>\d+)D)?'.
'(?:T(?:(?P<H>\d+)H)?(?:(?P<M>\d+)M)?(?:(?P<S>\d+)S)?)?'.
')'.
')'.
'\z/';
$matches = null;
$ok = preg_match($pattern, $value, $matches);
if (!$ok) {
throw new Exception(
pht(
'Expected ISO8601 duration in the format "P12DT3H4M5S", found '.
'"%s".',
$value));
}
$is_negative = (idx($matches, 'sign') == '-');
return id(new self())
->setIsNegative($is_negative)
->setWeeks((int)idx($matches, 'W', 0))
->setDays((int)idx($matches, 'D', 0))
->setHours((int)idx($matches, 'H', 0))
->setMinutes((int)idx($matches, 'M', 0))
->setSeconds((int)idx($matches, 'S', 0));
}
public function toISO8601() {
$parts = array();
$parts[] = 'P';
$weeks = $this->getWeeks();
if ($weeks) {
$parts[] = $weeks.'W';
} else {
$days = $this->getDays();
if ($days) {
$parts[] = $days.'D';
}
$parts[] = 'T';
$hours = $this->getHours();
if ($hours) {
$parts[] = $hours.'H';
}
$minutes = $this->getMinutes();
if ($minutes) {
$parts[] = $minutes.'M';
}
$seconds = $this->getSeconds();
if ($seconds) {
$parts[] = $seconds.'S';
}
}
return implode('', $parts);
}
public function setIsNegative($is_negative) {
$this->isNegative = $is_negative;
return $this;
}
public function getIsNegative() {
return $this->isNegative;
}
public function setWeeks($weeks) {
$this->weeks = $weeks;
return $this;
}
public function getWeeks() {
return $this->weeks;
}
public function setDays($days) {
$this->days = $days;
return $this;
}
public function getDays() {
return $this->days;
}
public function setHours($hours) {
$this->hours = $hours;
return $this;
}
public function getHours() {
return $this->hours;
}
public function setMinutes($minutes) {
$this->minutes = $minutes;
return $this;
}
public function getMinutes() {
return $this->minutes;
}
public function setSeconds($seconds) {
$this->seconds = $seconds;
return $this;
}
public function getSeconds() {
return $this->seconds;
}
}

View file

@ -0,0 +1,172 @@
<?php
final class PhutilCalendarEventNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'event';
private $uid;
private $name;
private $description;
private $startDateTime;
private $endDateTime;
private $duration;
private $createdDateTime;
private $modifiedDateTime;
private $organizer;
private $attendees = array();
private $recurrenceRule;
private $recurrenceExceptions = array();
private $recurrenceDates = array();
private $recurrenceID;
public function setUID($uid) {
$this->uid = $uid;
return $this;
}
public function getUID() {
return $this->uid;
}
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setDescription($description) {
$this->description = $description;
return $this;
}
public function getDescription() {
return $this->description;
}
public function setStartDateTime(PhutilCalendarDateTime $start) {
$this->startDateTime = $start;
return $this;
}
public function getStartDateTime() {
return $this->startDateTime;
}
public function setEndDateTime(PhutilCalendarDateTime $end) {
$this->endDateTime = $end;
return $this;
}
public function getEndDateTime() {
$end = $this->endDateTime;
if ($end) {
return $end;
}
$start = $this->getStartDateTime();
$duration = $this->getDuration();
if ($start && $duration) {
return id(new PhutilCalendarRelativeDateTime())
->setOrigin($start)
->setDuration($duration);
}
// If no end date or duration are specified, the event is instantaneous.
return $start;
}
public function setDuration(PhutilCalendarDuration $duration) {
$this->duration = $duration;
return $this;
}
public function getDuration() {
return $this->duration;
}
public function setCreatedDateTime(PhutilCalendarDateTime $created) {
$this->createdDateTime = $created;
return $this;
}
public function getCreatedDateTime() {
return $this->createdDateTime;
}
public function setModifiedDateTime(PhutilCalendarDateTime $modified) {
$this->modifiedDateTime = $modified;
return $this;
}
public function getModifiedDateTime() {
return $this->modifiedDateTime;
}
public function setOrganizer(PhutilCalendarUserNode $organizer) {
$this->organizer = $organizer;
return $this;
}
public function getOrganizer() {
return $this->organizer;
}
public function setAttendees(array $attendees) {
assert_instances_of($attendees, 'PhutilCalendarUserNode');
$this->attendees = $attendees;
return $this;
}
public function getAttendees() {
return $this->attendees;
}
public function addAttendee(PhutilCalendarUserNode $attendee) {
$this->attendees[] = $attendee;
return $this;
}
public function setRecurrenceRule(
PhutilCalendarRecurrenceRule $recurrence_rule) {
$this->recurrenceRule = $recurrence_rule;
return $this;
}
public function getRecurrenceRule() {
return $this->recurrenceRule;
}
public function setRecurrenceExceptions(array $recurrence_exceptions) {
assert_instances_of($recurrence_exceptions, 'PhutilCalendarDateTime');
$this->recurrenceExceptions = $recurrence_exceptions;
return $this;
}
public function getRecurrenceExceptions() {
return $this->recurrenceExceptions;
}
public function setRecurrenceDates(array $recurrence_dates) {
assert_instances_of($recurrence_dates, 'PhutilCalendarDateTime');
$this->recurrenceDates = $recurrence_dates;
return $this;
}
public function getRecurrenceDates() {
return $this->recurrenceDates;
}
public function setRecurrenceID($recurrence_id) {
$this->recurrenceID = $recurrence_id;
return $this;
}
public function getRecurrenceID() {
return $this->recurrenceID;
}
}

View file

@ -0,0 +1,20 @@
<?php
abstract class PhutilCalendarNode extends Phobject {
private $attributes = array();
final public function getNodeType() {
return $this->getPhobjectClassConstant('NODETYPE');
}
final public function setAttribute($key, $value) {
$this->attributes[$key] = $value;
return $this;
}
final public function getAttribute($key, $default = null) {
return idx($this->attributes, $key, $default);
}
}

View file

@ -0,0 +1,51 @@
<?php
abstract class PhutilCalendarProxyDateTime
extends PhutilCalendarDateTime {
private $proxy;
final protected function setProxy(PhutilCalendarDateTime $proxy) {
$this->proxy = $proxy;
return $this;
}
final protected function getProxy() {
return $this->proxy;
}
public function __clone() {
$this->proxy = clone $this->proxy;
}
public function setViewerTimezone($timezone) {
$this->getProxy()->setViewerTimezone($timezone);
return $this;
}
public function getViewerTimezone() {
return $this->getProxy()->getViewerTimezone();
}
public function setIsAllDay($is_all_day) {
$this->getProxy()->setIsAllDay($is_all_day);
return $this;
}
public function getIsAllDay() {
return $this->getProxy()->getIsAllDay();
}
public function newPHPDateTimezone() {
return $this->getProxy()->newPHPDateTimezone();
}
public function newPHPDateTime() {
return $this->getProxy()->newPHPDateTime();
}
public function getTimezone() {
return $this->getProxy()->getTimezone();
}
}

View file

@ -0,0 +1,8 @@
<?php
final class PhutilCalendarRawNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'raw';
}

View file

@ -0,0 +1,43 @@
<?php
final class PhutilCalendarRecurrenceList
extends PhutilCalendarRecurrenceSource {
private $dates = array();
private $order;
public function setDates(array $dates) {
assert_instances_of($dates, 'PhutilCalendarDateTime');
$this->dates = $dates;
return $this;
}
public function getDates() {
return $this->dates;
}
public function resetSource() {
foreach ($this->getDates() as $date) {
$date->setViewerTimezone($this->getViewerTimezone());
}
$order = msort($this->getDates(), 'getEpoch');
$order = array_reverse($order);
$this->order = $order;
return $this;
}
public function getNextEvent($cursor) {
while ($this->order) {
$next = array_pop($this->order);
if ($next->getEpoch() >= $cursor) {
return $next;
}
}
return null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
<?php
final class PhutilCalendarRecurrenceSet
extends Phobject {
private $sources = array();
private $viewerTimezone = 'UTC';
public function addSource(PhutilCalendarRecurrenceSource $source) {
$this->sources[] = $source;
return $this;
}
public function setViewerTimezone($viewer_timezone) {
$this->viewerTimezone = $viewer_timezone;
return $this;
}
public function getViewerTimezone() {
return $this->viewerTimezone;
}
public function getEventsBetween(
PhutilCalendarDateTime $start = null,
PhutilCalendarDateTime $end = null,
$limit = null) {
if ($end === null && $limit === null) {
throw new Exception(
pht(
'Recurring event range queries must have an end date, a limit, or '.
'both.'));
}
$timezone = $this->getViewerTimezone();
$sources = array();
foreach ($this->sources as $source) {
$source = clone $source;
$source->setViewerTimezone($timezone);
$source->resetSource();
$sources[] = array(
'source' => $source,
'state' => null,
'epoch' => null,
);
}
if ($start) {
$start = clone $start;
$start->setViewerTimezone($timezone);
$min_epoch = $start->getEpoch();
} else {
$min_epoch = 0;
}
if ($end) {
$end = clone $end;
$end->setViewerTimezone($timezone);
$end_epoch = $end->getEpoch();
} else {
$end_epoch = null;
}
$results = array();
$index = 0;
$cursor = 0;
while (true) {
// Get the next event for each source which we don't have a future
// event for.
foreach ($sources as $key => $source) {
$state = $source['state'];
$epoch = $source['epoch'];
if ($state !== null && $epoch >= $cursor) {
// We have an event for this source, and it's a future event, so
// we don't need to do anything.
continue;
}
$next = $source['source']->getNextEvent($cursor);
if ($next === null) {
// This source doesn't have any more events, so we're all done.
unset($sources[$key]);
continue;
}
$next_epoch = $next->getEpoch();
if ($end_epoch !== null && $next_epoch > $end_epoch) {
// We have an end time and the next event from this source is
// past that end, so we know there are no more relevant events
// coming from this source.
unset($sources[$key]);
continue;
}
$sources[$key]['state'] = $next;
$sources[$key]['epoch'] = $next_epoch;
}
if (!$sources) {
// We've run out of sources which can produce valid events in the
// window, so we're all done.
break;
}
// Find the minimum event time across all sources.
$next_epoch = null;
foreach ($sources as $source) {
if ($next_epoch === null) {
$next_epoch = $source['epoch'];
} else {
$next_epoch = min($next_epoch, $source['epoch']);
}
}
$is_exception = false;
$next_source = null;
foreach ($sources as $source) {
if ($source['epoch'] == $next_epoch) {
if ($source['source']->getIsExceptionSource()) {
$is_exception = true;
} else {
$next_source = $source;
}
}
}
// If this is an exception, it means the event does NOT occur. We
// skip it and move on. If it's not an exception, it does occur, so
// we record it.
if (!$is_exception) {
// Only actually include this event in the results if it starts after
// any specified start time. We increment the index regardless, so we
// return results with proper offsets.
if ($next_source['epoch'] >= $min_epoch) {
$results[$index] = $next_source['state'];
}
$index++;
if ($limit !== null && (count($results) >= $limit)) {
break;
}
}
$cursor = $next_epoch + 1;
// If we have an end of the window and we've reached it, we're done.
if ($end_epoch) {
if ($cursor > $end_epoch) {
break;
}
}
}
return $results;
}
}

View file

@ -0,0 +1,34 @@
<?php
abstract class PhutilCalendarRecurrenceSource
extends Phobject {
private $isExceptionSource;
private $viewerTimezone;
public function setIsExceptionSource($is_exception_source) {
$this->isExceptionSource = $is_exception_source;
return $this;
}
public function getIsExceptionSource() {
return $this->isExceptionSource;
}
public function setViewerTimezone($viewer_timezone) {
$this->viewerTimezone = $viewer_timezone;
return $this;
}
public function getViewerTimezone() {
return $this->viewerTimezone;
}
public function resetSource() {
return;
}
abstract public function getNextEvent($cursor);
}

View file

@ -0,0 +1,74 @@
<?php
final class PhutilCalendarRelativeDateTime
extends PhutilCalendarProxyDateTime {
private $duration;
public function setOrigin(PhutilCalendarDateTime $origin) {
return $this->setProxy($origin);
}
public function getOrigin() {
return $this->getProxy();
}
public function setDuration(PhutilCalendarDuration $duration) {
$this->duration = $duration;
return $this;
}
public function getDuration() {
return $this->duration;
}
public function newPHPDateTime() {
$datetime = parent::newPHPDateTime();
$duration = $this->getDuration();
if ($duration->getIsNegative()) {
$sign = '-';
} else {
$sign = '+';
}
$map = array(
'weeks' => $duration->getWeeks(),
'days' => $duration->getDays(),
'hours' => $duration->getHours(),
'minutes' => $duration->getMinutes(),
'seconds' => $duration->getSeconds(),
);
foreach ($map as $unit => $value) {
if (!$value) {
continue;
}
$datetime->modify("{$sign}{$value} {$unit}");
}
return $datetime;
}
public function newAbsoluteDateTime() {
$clone = clone $this;
if ($clone->getTimezone()) {
$clone->setViewerTimezone(null);
}
$datetime = $clone->newPHPDateTime();
return id(new PhutilCalendarAbsoluteDateTime())
->setYear((int)$datetime->format('Y'))
->setMonth((int)$datetime->format('m'))
->setDay((int)$datetime->format('d'))
->setHour((int)$datetime->format('H'))
->setMinute((int)$datetime->format('i'))
->setSecond((int)$datetime->format('s'))
->setIsAllDay($clone->getIsAllDay())
->setTimezone($clone->getTimezone())
->setViewerTimezone($this->getViewerTimezone());
}
}

View file

@ -0,0 +1,12 @@
<?php
final class PhutilCalendarRootNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'root';
public function getDocuments() {
return $this->getChildrenOfType(PhutilCalendarDocumentNode::NODETYPE);
}
}

View file

@ -0,0 +1,40 @@
<?php
final class PhutilCalendarUserNode extends PhutilCalendarNode {
private $name;
private $uri;
private $status;
const STATUS_INVITED = 'invited';
const STATUS_ACCEPTED = 'accepted';
const STATUS_DECLINED = 'declined';
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setURI($uri) {
$this->uri = $uri;
return $this;
}
public function getURI() {
return $this->uri;
}
public function setStatus($status) {
$this->status = $status;
return $this;
}
public function getStatus() {
return $this->status;
}
}

View file

@ -0,0 +1,49 @@
<?php
final class PhutilCalendarDateTimeTestCase extends PhutilTestCase {
public function testDateTimeDuration() {
$start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20161128T090000Z')
->setTimezone('America/Los_Angeles')
->setViewerTimezone('America/Chicago')
->setIsAllDay(true);
$this->assertEqual(
'20161128',
$start->getISO8601());
$end = $start
->newAbsoluteDateTime()
->setHour(0)
->setMinute(0)
->setSecond(0)
->newRelativeDateTime('P1D')
->newAbsoluteDateTime();
$this->assertEqual(
'20161129',
$end->getISO8601());
// This is a date which explicitly has no specified timezone.
$start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20161128', null)
->setViewerTimezone('UTC');
$this->assertEqual(
'20161128',
$start->getISO8601());
$end = $start
->newAbsoluteDateTime()
->setHour(0)
->setMinute(0)
->setSecond(0)
->newRelativeDateTime('P1D')
->newAbsoluteDateTime();
$this->assertEqual(
'20161129',
$end->getISO8601());
}
}

View file

@ -0,0 +1,196 @@
<?php
final class PhutilCalendarRecurrenceTestCase extends PhutilTestCase {
public function testCalendarRecurrenceLists() {
$set = id(new PhutilCalendarRecurrenceSet());
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
array(),
$result,
pht('Set with no sources.'));
$set = id(new PhutilCalendarRecurrenceSet())
->addSource(new PhutilCalendarRecurrenceList());
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
array(),
$result,
pht('Set with empty list source.'));
$list = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
);
$source = id(new PhutilCalendarRecurrenceList())
->setDates($list);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($source);
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
);
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Simple date list.'));
$list_a = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
);
$list_b = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
);
$source_a = id(new PhutilCalendarRecurrenceList())
->setDates($list_a);
$source_b = id(new PhutilCalendarRecurrenceList())
->setDates($list_b);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($source_a)
->addSource($source_b);
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
);
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Multiple date lists.'));
$list_a = array(
// This is Jan 1, 3, 5, 7, 8 and 10, but listed out-of-order.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160110T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160107T120000Z'),
);
$list_b = array(
// This is Jan 2, 4, 5, 8, but listed out of order.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'),
);
$list_c = array(
// We're going to use this as an exception list.
// This is Jan 7 (listed in one other source), 8 (listed in two)
// and 9 (listed in none).
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160107T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160109T120000Z'),
);
$expect = array(
// From source A.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
// From source B.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
// From source A.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
// From source B.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'),
// From source A and B. Should appear only once.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'),
// The 6th appears in no source.
// The 7th, 8th and 9th are excluded.
// The 10th is from source A.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160110T120000Z'),
);
$list_a = id(new PhutilCalendarRecurrenceList())
->setDates($list_a);
$list_b = id(new PhutilCalendarRecurrenceList())
->setDates($list_b);
$list_c = id(new PhutilCalendarRecurrenceList())
->setDates($list_c)
->setIsExceptionSource(true);
$date_set = id(new PhutilCalendarRecurrenceSet())
->addSource($list_b)
->addSource($list_c)
->addSource($list_a);
$date_set->setViewerTimezone('UTC');
$result = $date_set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Set of all results in multiple lists with exclusions.'));
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
);
$result = $date_set->getEventsBetween(null, null, 1);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Multiple lists, one result.'));
$expect = array(
2 => PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
3 => PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'),
);
$result = $date_set->getEventsBetween(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'));
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Multiple lists, time window.'));
}
public function testCalendarRecurrenceOffsets() {
$list = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
);
$source = id(new PhutilCalendarRecurrenceList())
->setDates($list);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($source);
$t1 = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120001Z');
$t2 = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z');
$expect = array(
2 => $t2,
);
$result = $set->getEventsBetween($t1, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Correct event indexes with start date.'));
}
}

View file

@ -0,0 +1,919 @@
<?php
final class PhutilICSParser extends Phobject {
private $stack;
private $node;
private $document;
private $lines;
private $cursor;
private $warnings;
const PARSE_MISSING_END = 'missing-end';
const PARSE_INITIAL_UNFOLD = 'initial-unfold';
const PARSE_UNEXPECTED_CHILD = 'unexpected-child';
const PARSE_EXTRA_END = 'extra-end';
const PARSE_MISMATCHED_SECTIONS = 'mismatched-sections';
const PARSE_ROOT_PROPERTY = 'root-property';
const PARSE_BAD_BASE64 = 'bad-base64';
const PARSE_BAD_BOOLEAN = 'bad-boolean';
const PARSE_UNEXPECTED_TEXT = 'unexpected-text';
const PARSE_MALFORMED_DOUBLE_QUOTE = 'malformed-double-quote';
const PARSE_MALFORMED_PARAMETER_NAME = 'malformed-parameter';
const PARSE_MALFORMED_PROPERTY = 'malformed-property';
const PARSE_MISSING_VALUE = 'missing-value';
const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash';
const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters';
const PARSE_EMPTY_DATETIME = 'empty-datetime';
const PARSE_MANY_DATETIME = 'many-datetime';
const PARSE_BAD_DATETIME = 'bad-datetime';
const PARSE_EMPTY_DURATION = 'empty-duration';
const PARSE_MANY_DURATION = 'many-duration';
const PARSE_BAD_DURATION = 'bad-duration';
const WARN_TZID_UTC = 'warn-tzid-utc';
const WARN_TZID_GUESS = 'warn-tzid-guess';
const WARN_TZID_IGNORED = 'warn-tzid-ignored';
public function parseICSData($data) {
$this->stack = array();
$this->node = null;
$this->cursor = null;
$this->warnings = array();
$lines = $this->unfoldICSLines($data);
$this->lines = $lines;
$root = $this->newICSNode('<ROOT>');
$this->stack[] = $root;
$this->node = $root;
foreach ($lines as $key => $line) {
$this->cursor = $key;
$matches = null;
if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) {
$this->beginParsingNode($matches[1]);
} else if (preg_match('(^END:(.*)\z)', $line, $matches)) {
$this->endParsingNode($matches[1]);
} else {
if (count($this->stack) < 2) {
$this->raiseParseFailure(
self::PARSE_ROOT_PROPERTY,
pht(
'Found unexpected property at ICS document root.'));
}
$this->parseICSProperty($line);
}
}
if (count($this->stack) > 1) {
$this->raiseParseFailure(
self::PARSE_MISSING_END,
pht(
'Expected all "BEGIN:" sections in ICS document to have '.
'corresponding "END:" sections.'));
}
$this->node = null;
$this->lines = null;
$this->cursor = null;
return $root;
}
private function getNode() {
return $this->node;
}
private function unfoldICSLines($data) {
$lines = phutil_split_lines($data, $retain_endings = false);
$this->lines = $lines;
// ICS files are wrapped at 75 characters, with overlong lines continued
// on the following line with an initial space or tab. Unwrap all of the
// lines in the file.
// This unwrapping is specifically byte-oriented, not character oriented,
// and RFC5545 anticipates that simple implementations may even split UTF8
// characters in the middle.
$last = null;
foreach ($lines as $idx => $line) {
$this->cursor = $idx;
if (!preg_match('/^[ \t]/', $line)) {
$last = $idx;
continue;
}
if ($last === null) {
$this->raiseParseFailure(
self::PARSE_INITIAL_UNFOLD,
pht(
'First line of ICS file begins with a space or tab, but this '.
'marks a line which should be unfolded.'));
}
$lines[$last] = $lines[$last].substr($line, 1);
unset($lines[$idx]);
}
return $lines;
}
private function beginParsingNode($type) {
$node = $this->getNode();
$new_node = $this->newICSNode($type);
if ($node instanceof PhutilCalendarContainerNode) {
$node->appendChild($new_node);
} else {
$this->raiseParseFailure(
self::PARSE_UNEXPECTED_CHILD,
pht(
'Found unexpected node "%s" inside node "%s".',
$new_node->getAttribute('ics.type'),
$node->getAttribute('ics.type')));
}
$this->stack[] = $new_node;
$this->node = $new_node;
return $this;
}
private function newICSNode($type) {
switch ($type) {
case '<ROOT>':
$node = new PhutilCalendarRootNode();
break;
case 'VCALENDAR':
$node = new PhutilCalendarDocumentNode();
break;
case 'VEVENT':
$node = new PhutilCalendarEventNode();
break;
default:
$node = new PhutilCalendarRawNode();
break;
}
$node->setAttribute('ics.type', $type);
return $node;
}
private function endParsingNode($type) {
$node = $this->getNode();
if ($node instanceof PhutilCalendarRootNode) {
$this->raiseParseFailure(
self::PARSE_EXTRA_END,
pht(
'Found unexpected "END" without a "BEGIN".'));
}
$old_type = $node->getAttribute('ics.type');
if ($old_type != $type) {
$this->raiseParseFailure(
self::PARSE_MISMATCHED_SECTIONS,
pht(
'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.',
$old_type,
$type));
}
array_pop($this->stack);
$this->node = last($this->stack);
return $this;
}
private function parseICSProperty($line) {
$matches = null;
// Properties begin with an alphanumeric name with no escaping, followed
// by either a ";" (to begin a list of parameters) or a ":" (to begin
// the actual field body).
$ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_PROPERTY,
pht(
'Found malformed property in ICS document.'));
}
$name = $matches[1];
$body = $matches[3];
$has_parameters = ($matches[2] == ';');
$parameters = array();
if ($has_parameters) {
// Parameters are a sensible name, a literal "=", a pile of magic,
// and then maybe a comma and another parameter.
while (true) {
// We're going to get the first couple of parts first.
$ok = preg_match('(^([^=]+)=)', $body, $matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_PARAMETER_NAME,
pht(
'Found malformed property in ICS document: %s',
$body));
}
$param_name = $matches[1];
$body = substr($body, strlen($matches[0]));
// Now we're going to match zero or more values.
$param_values = array();
while (true) {
// The value can either be a double-quoted string or an unquoted
// string, with some characters forbidden.
if (strlen($body) && $body[0] == '"') {
$is_quoted = true;
$ok = preg_match(
'(^"([^\x00-\x08\x10-\x19"]*)")',
$body,
$matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_DOUBLE_QUOTE,
pht(
'Found malformed double-quoted string in ICS document '.
'parameter value.'));
}
} else {
$is_quoted = false;
// It's impossible for this not to match since it can match
// nothing, and it's valid for it to match nothing.
preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches);
}
// NOTE: RFC5545 says "Property parameter values that are not in
// quoted-strings are case-insensitive." -- that is, the quoted and
// unquoted representations are not equivalent. Thus, preserve the
// original formatting in case we ever need to respect this.
$param_values[] = array(
'value' => $this->unescapeParameterValue($matches[1]),
'quoted' => $is_quoted,
);
$body = substr($body, strlen($matches[0]));
if (!strlen($body)) {
$this->raiseParseFailure(
self::PARSE_MISSING_VALUE,
pht(
'Expected ":" after parameters in ICS document property.'));
}
// If we have a comma now, we're going to read another value. Strip
// it off and keep going.
if ($body[0] == ',') {
$body = substr($body, 1);
continue;
}
// If we have a semicolon, we're going to read another parameter.
if ($body[0] == ';') {
break;
}
// If we have a colon, this is the last value and also the last
// property. Break, then handle the colon below.
if ($body[0] == ':') {
break;
}
$short_body = id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(32)
->truncateString($body);
// We aren't expecting anything else.
$this->raiseParseFailure(
self::PARSE_UNEXPECTED_TEXT,
pht(
'Found unexpected text ("%s") after reading parameter value.',
$short_body));
}
$parameters[] = array(
'name' => $param_name,
'values' => $param_values,
);
if ($body[0] == ';') {
$body = substr($body, 1);
continue;
}
if ($body[0] == ':') {
$body = substr($body, 1);
break;
}
}
}
$value = $this->unescapeFieldValue($name, $parameters, $body);
$node = $this->getNode();
$raw = $node->getAttribute('ics.properties', array());
$raw[] = array(
'name' => $name,
'parameters' => $parameters,
'value' => $value,
);
$node->setAttribute('ics.properties', $raw);
switch ($node->getAttribute('ics.type')) {
case 'VEVENT':
$this->didParseEventProperty($node, $name, $parameters, $value);
break;
}
}
private function unescapeParameterValue($data) {
// The parameter grammar is adjusted by RFC6868 to permit escaping with
// carets. Remove that escaping.
// This escaping is a bit weird because it's trying to be backwards
// compatible and the original spec didn't think about this and didn't
// provide much room to fix things.
$out = '';
$esc = false;
foreach (phutil_utf8v($data) as $c) {
if (!$esc) {
if ($c != '^') {
$out .= $c;
} else {
$esc = true;
}
} else {
switch ($c) {
case 'n':
$out .= "\n";
break;
case '^':
$out .= '^';
break;
case "'":
// NOTE: This is "<caret> <single quote>" being decoded into a
// double quote!
$out .= '"';
break;
default:
// NOTE: The caret is NOT an escape for any other characters.
// This is a "MUST" requirement of RFC6868.
$out .= '^'.$c;
break;
}
}
}
// NOTE: Because caret on its own just means "caret" for backward
// compatibility, we don't warn if we're still in escaped mode once we
// reach the end of the string.
return $out;
}
private function unescapeFieldValue($name, array $parameters, $data) {
// NOTE: The encoding of the field value data is dependent on the field
// name (which defines a default encoding) and the parameters (which may
// include "VALUE", specifying a type of the data.
$default_types = array(
'CALSCALE' => 'TEXT',
'METHOD' => 'TEXT',
'PRODID' => 'TEXT',
'VERSION' => 'TEXT',
'ATTACH' => 'URI',
'CATEGORIES' => 'TEXT',
'CLASS' => 'TEXT',
'COMMENT' => 'TEXT',
'DESCRIPTION' => 'TEXT',
// TODO: The spec appears to contradict itself: it says that the value
// type is FLOAT, but it also says that this property value is actually
// two semicolon-separated values, which is not what FLOAT is defined as.
'GEO' => 'TEXT',
'LOCATION' => 'TEXT',
'PERCENT-COMPLETE' => 'INTEGER',
'PRIORITY' => 'INTEGER',
'RESOURCES' => 'TEXT',
'STATUS' => 'TEXT',
'SUMMARY' => 'TEXT',
'COMPLETED' => 'DATE-TIME',
'DTEND' => 'DATE-TIME',
'DUE' => 'DATE-TIME',
'DTSTART' => 'DATE-TIME',
'DURATION' => 'DURATION',
'FREEBUSY' => 'PERIOD',
'TRANSP' => 'TEXT',
'TZID' => 'TEXT',
'TZNAME' => 'TEXT',
'TZOFFSETFROM' => 'UTC-OFFSET',
'TZOFFSETTO' => 'UTC-OFFSET',
'TZURL' => 'URI',
'ATTENDEE' => 'CAL-ADDRESS',
'CONTACT' => 'TEXT',
'ORGANIZER' => 'CAL-ADDRESS',
'RECURRENCE-ID' => 'DATE-TIME',
'RELATED-TO' => 'TEXT',
'URL' => 'URI',
'UID' => 'TEXT',
'EXDATE' => 'DATE-TIME',
'RDATE' => 'DATE-TIME',
'RRULE' => 'RECUR',
'ACTION' => 'TEXT',
'REPEAT' => 'INTEGER',
'TRIGGER' => 'DURATION',
'CREATED' => 'DATE-TIME',
'DTSTAMP' => 'DATE-TIME',
'LAST-MODIFIED' => 'DATE-TIME',
'SEQUENCE' => 'INTEGER',
'REQUEST-STATUS' => 'TEXT',
);
$value_type = idx($default_types, $name, 'TEXT');
foreach ($parameters as $parameter) {
if ($parameter['name'] == 'VALUE') {
$value_type = idx(head($parameter['values']), 'value');
}
}
switch ($value_type) {
case 'BINARY':
$result = base64_decode($data, true);
if ($result === false) {
$this->raiseParseFailure(
self::PARSE_BAD_BASE64,
pht(
'Unable to decode base64 data: %s',
$data));
}
break;
case 'BOOLEAN':
$map = array(
'true' => true,
'false' => false,
);
$result = phutil_utf8_strtolower($data);
if (!isset($map[$result])) {
$this->raiseParseFailure(
self::PARSE_BAD_BOOLEAN,
pht(
'Unexpected BOOLEAN value "%s".',
$data));
}
$result = $map[$result];
break;
case 'CAL-ADDRESS':
$result = $data;
break;
case 'DATE':
// This is a comma-separated list of "YYYYMMDD" values.
$result = explode(',', $data);
break;
case 'DATE-TIME':
if (!strlen($data)) {
$result = array();
} else {
$result = explode(',', $data);
}
break;
case 'DURATION':
if (!strlen($data)) {
$result = array();
} else {
$result = explode(',', $data);
}
break;
case 'FLOAT':
$result = explode(',', $data);
foreach ($result as $k => $v) {
$result[$k] = (float)$v;
}
break;
case 'INTEGER':
$result = explode(',', $data);
foreach ($result as $k => $v) {
$result[$k] = (int)$v;
}
break;
case 'PERIOD':
$result = explode(',', $data);
break;
case 'RECUR':
$result = $data;
break;
case 'TEXT':
$result = $this->unescapeTextValue($data);
break;
case 'TIME':
$result = explode(',', $data);
break;
case 'URI':
$result = $data;
break;
case 'UTC-OFFSET':
$result = $data;
break;
default:
// RFC5545 says we MUST preserve the data for any types we don't
// recognize.
$result = $data;
break;
}
return array(
'type' => $value_type,
'value' => $result,
'raw' => $data,
);
}
private function unescapeTextValue($data) {
$result = array();
$buf = '';
$esc = false;
foreach (phutil_utf8v($data) as $c) {
if (!$esc) {
if ($c == '\\') {
$esc = true;
} else if ($c == ',') {
$result[] = $buf;
$buf = '';
} else {
$buf .= $c;
}
} else {
switch ($c) {
case 'n':
case 'N':
$buf .= "\n";
break;
default:
$buf .= $c;
break;
}
$esc = false;
}
}
if ($esc) {
$this->raiseParseFailure(
self::PARSE_UNESCAPED_BACKSLASH,
pht(
'ICS document contains TEXT value ending with unescaped '.
'backslash.'));
}
$result[] = $buf;
return $result;
}
private function raiseParseFailure($code, $message) {
if ($this->lines && isset($this->lines[$this->cursor])) {
$message = pht(
"ICS Parse Error near line %s:\n\n>>> %s\n\n%s",
$this->cursor + 1,
$this->lines[$this->cursor],
$message);
} else {
$message = pht(
'ICS Parse Error: %s',
$message);
}
throw id(new PhutilICSParserException($message))
->setParserFailureCode($code);
}
private function raiseWarning($code, $message) {
$this->warnings[] = array(
'code' => $code,
'line' => $this->cursor,
'text' => $this->lines[$this->cursor],
'message' => $message,
);
return $this;
}
public function getWarnings() {
return $this->warnings;
}
private function didParseEventProperty(
PhutilCalendarEventNode $node,
$name,
array $parameters,
array $value) {
switch ($name) {
case 'UID':
$text = $this->newTextFromProperty($parameters, $value);
$node->setUID($text);
break;
case 'CREATED':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setCreatedDateTime($datetime);
break;
case 'DTSTAMP':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setModifiedDateTime($datetime);
break;
case 'SUMMARY':
$text = $this->newTextFromProperty($parameters, $value);
$node->setName($text);
break;
case 'DESCRIPTION':
$text = $this->newTextFromProperty($parameters, $value);
$node->setDescription($text);
break;
case 'DTSTART':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setStartDateTime($datetime);
break;
case 'DTEND':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setEndDateTime($datetime);
break;
case 'DURATION':
$duration = $this->newDurationFromProperty($parameters, $value);
$node->setDuration($duration);
break;
case 'RRULE':
$rrule = $this->newRecurrenceRuleFromProperty($parameters, $value);
$node->setRecurrenceRule($rrule);
break;
case 'RECURRENCE-ID':
$text = $this->newTextFromProperty($parameters, $value);
$node->setRecurrenceID($text);
break;
case 'ATTENDEE':
$attendee = $this->newAttendeeFromProperty($parameters, $value);
$node->addAttendee($attendee);
break;
}
}
private function newTextFromProperty(array $parameters, array $value) {
$value = $value['value'];
return implode("\n\n", $value);
}
private function newAttendeeFromProperty(array $parameters, array $value) {
$uri = $value['value'];
switch (idx($parameters, 'PARTSTAT')) {
case 'ACCEPTED':
$status = PhutilCalendarUserNode::STATUS_ACCEPTED;
break;
case 'DECLINED':
$status = PhutilCalendarUserNode::STATUS_DECLINED;
break;
case 'NEEDS-ACTION':
default:
$status = PhutilCalendarUserNode::STATUS_INVITED;
break;
}
$name = $this->getScalarParameterValue($parameters, 'CN');
return id(new PhutilCalendarUserNode())
->setURI($uri)
->setName($name)
->setStatus($status);
}
private function newDateTimeFromProperty(array $parameters, array $value) {
$value = $value['value'];
if (!$value) {
$this->raiseParseFailure(
self::PARSE_EMPTY_DATETIME,
pht(
'Expected DATE-TIME to have exactly one value, found none.'));
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MANY_DATETIME,
pht(
'Expected DATE-TIME to have exactly one value, found more than '.
'one.'));
}
$value = head($value);
$tzid = $this->getScalarParameterValue($parameters, 'TZID');
if (preg_match('/Z\z/', $value)) {
if ($tzid) {
$this->raiseWarning(
self::WARN_TZID_UTC,
pht(
'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '.
'parameter with value "%s". This violates RFC5545. The TZID '.
'will be ignored, and the value will be interpreted as UTC.',
$value,
$tzid));
}
$tzid = 'UTC';
} else if ($tzid !== null) {
$tzid = $this->guessTimezone($tzid);
}
try {
$datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
$value,
$tzid);
} catch (Exception $ex) {
$this->raiseParseFailure(
self::PARSE_BAD_DATETIME,
pht(
'Error parsing DATE-TIME: %s',
$ex->getMessage()));
}
return $datetime;
}
private function newDurationFromProperty(array $parameters, array $value) {
$value = $value['value'];
if (!$value) {
$this->raiseParseFailure(
self::PARSE_EMPTY_DURATION,
pht(
'Expected DURATION to have exactly one value, found none.'));
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MANY_DURATION,
pht(
'Expected DURATION to have exactly one value, found more than '.
'one.'));
}
$value = head($value);
try {
$duration = PhutilCalendarDuration::newFromISO8601($value);
} catch (Exception $ex) {
$this->raiseParseFailure(
self::PARSE_BAD_DURATION,
pht(
'Invalid DURATION: %s',
$ex->getMessage()));
}
return $duration;
}
private function newRecurrenceRuleFromProperty(array $parameters, $value) {
return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']);
}
private function getScalarParameterValue(
array $parameters,
$name,
$default = null) {
$match = null;
foreach ($parameters as $parameter) {
if ($parameter['name'] == $name) {
$match = $parameter;
}
}
if ($match === null) {
return $default;
}
$value = $match['values'];
if (!$value) {
// Parameter is specified, but with no value, like "KEY=". Just return
// the default, as though the parameter was not specified.
return $default;
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MULTIPLE_PARAMETERS,
pht(
'Expected parameter "%s" to have at most one value, but found '.
'more than one.',
$name));
}
return idx(head($value), 'value');
}
private function guessTimezone($tzid) {
$map = DateTimeZone::listIdentifiers();
$map = array_fuse($map);
if (isset($map[$tzid])) {
// This is a real timezone we recognize, so just use it as provided.
return $tzid;
}
// These are alternate names for timezones.
static $aliases;
if ($aliases === null) {
$aliases = array(
'Etc/GMT' => 'UTC',
);
// Load the map of Windows timezones.
$root_path = dirname(phutil_get_library_root('phutil'));
$windows_path = $root_path.'/resources/timezones/windows_timezones.json';
$windows_data = Filesystem::readFile($windows_path);
$windows_zones = phutil_json_decode($windows_data);
$aliases = $aliases + $windows_zones;
}
if (isset($aliases[$tzid])) {
return $aliases[$tzid];
}
// Look for something that looks like "UTC+3" or "GMT -05.00". If we find
// anything, pick a timezone with that offset.
$offset_pattern =
'/'.
'(?:UTC|GMT)'.
'\s*'.
'(?P<sign>[+-])'.
'\s*'.
'(?P<h>\d+)'.
'(?:'.
'[:.](?P<m>\d+)'.
')?'.
'/i';
$matches = null;
if (preg_match($offset_pattern, $tzid, $matches)) {
$hours = (int)$matches['h'];
$minutes = (int)idx($matches, 'm');
$offset = ($hours * 60 * 60) + ($minutes * 60);
if (idx($matches, 'sign') == '-') {
$offset = -$offset;
}
// NOTE: We could possibly do better than this, by using the event start
// time to guess a timezone. However, that won't work for recurring
// events and would require us to do this work after finishing initial
// parsing. Since these unusual offset-based timezones appear to be rare,
// the benefit may not be worth the complexity.
$now = new DateTime('@'.time());
foreach ($map as $identifier) {
$zone = new DateTimeZone($identifier);
if ($zone->getOffset($now) == $offset) {
$this->raiseWarning(
self::WARN_TZID_GUESS,
pht(
'TZID "%s" is unknown, guessing "%s" based on pattern "%s".',
$tzid,
$identifier,
$matches[0]));
return $identifier;
}
}
}
$this->raiseWarning(
self::WARN_TZID_IGNORED,
pht(
'TZID "%s" is unknown, using UTC instead.',
$tzid));
return 'UTC';
}
}

View file

@ -0,0 +1,16 @@
<?php
final class PhutilICSParserException extends Exception {
private $parserFailureCode;
public function setParserFailureCode($code) {
$this->parserFailureCode = $code;
return $this;
}
public function getParserFailureCode() {
return $this->parserFailureCode;
}
}

View file

@ -0,0 +1,387 @@
<?php
final class PhutilICSWriter extends Phobject {
public function writeICSDocument(PhutilCalendarRootNode $node) {
$out = array();
foreach ($node->getChildren() as $child) {
$out[] = $this->writeNode($child);
}
return implode('', $out);
}
private function writeNode(PhutilCalendarNode $node) {
if (!$this->getICSNodeType($node)) {
return null;
}
$out = array();
$out[] = $this->writeBeginNode($node);
$out[] = $this->writeNodeProperties($node);
if ($node instanceof PhutilCalendarContainerNode) {
foreach ($node->getChildren() as $child) {
$out[] = $this->writeNode($child);
}
}
$out[] = $this->writeEndNode($node);
return implode('', $out);
}
private function writeBeginNode(PhutilCalendarNode $node) {
$type = $this->getICSNodeType($node);
return $this->wrapICSLine("BEGIN:{$type}");
}
private function writeEndNode(PhutilCalendarNode $node) {
$type = $this->getICSNodeType($node);
return $this->wrapICSLine("END:{$type}");
}
private function writeNodeProperties(PhutilCalendarNode $node) {
$properties = $this->getNodeProperties($node);
$out = array();
foreach ($properties as $property) {
$propname = $property['name'];
$propvalue = $property['value'];
$propline = array();
$propline[] = $propname;
foreach ($property['parameters'] as $parameter) {
$paramname = $parameter['name'];
$paramvalue = $parameter['value'];
$propline[] = ";{$paramname}={$paramvalue}";
}
$propline[] = ":{$propvalue}";
$propline = implode('', $propline);
$out[] = $this->wrapICSLine($propline);
}
return implode('', $out);
}
private function getICSNodeType(PhutilCalendarNode $node) {
switch ($node->getNodeType()) {
case PhutilCalendarDocumentNode::NODETYPE:
return 'VCALENDAR';
case PhutilCalendarEventNode::NODETYPE:
return 'VEVENT';
default:
return null;
}
}
private function wrapICSLine($line) {
$out = array();
$buf = '';
// NOTE: The line may contain sequences of combining characters which are
// more than 80 bytes in length. If it does, we'll split them in the
// middle of the sequence. This is okay and generally anticipated by
// RFC5545, which even allows implementations to split multibyte
// characters. The sequence will be stitched back together properly by
// whatever is parsing things.
foreach (phutil_utf8v($line) as $character) {
// If adding this character would bring the line over 75 bytes, start
// a new line.
if (strlen($buf) + strlen($character) > 75) {
$out[] = $buf."\r\n";
$buf = ' ';
}
$buf .= $character;
}
$out[] = $buf."\r\n";
return implode('', $out);
}
private function getNodeProperties(PhutilCalendarNode $node) {
switch ($node->getNodeType()) {
case PhutilCalendarDocumentNode::NODETYPE:
return $this->getDocumentNodeProperties($node);
case PhutilCalendarEventNode::NODETYPE:
return $this->getEventNodeProperties($node);
default:
return array();
}
}
private function getDocumentNodeProperties(
PhutilCalendarDocumentNode $event) {
$properties = array();
$properties[] = $this->newTextProperty(
'VERSION',
'2.0');
$properties[] = $this->newTextProperty(
'PRODID',
'-//Phacility//Phabricator//EN');
return $properties;
}
private function getEventNodeProperties(PhutilCalendarEventNode $event) {
$properties = array();
$uid = $event->getUID();
if (!strlen($uid)) {
throw new Exception(
pht(
'Unable to write ICS document: event has no UID, but each event '.
'MUST have a UID.'));
}
$properties[] = $this->newTextProperty(
'UID',
$uid);
$created = $event->getCreatedDateTime();
if ($created) {
$properties[] = $this->newDateTimeProperty(
'CREATED',
$event->getCreatedDateTime());
}
$dtstamp = $event->getModifiedDateTime();
if (!$dtstamp) {
throw new Exception(
pht(
'Unable to write ICS document: event has no modified time, but '.
'each event MUST have a modified time.'));
}
$properties[] = $this->newDateTimeProperty(
'DTSTAMP',
$dtstamp);
$dtstart = $event->getStartDateTime();
if ($dtstart) {
$properties[] = $this->newDateTimeProperty(
'DTSTART',
$dtstart);
}
$dtend = $event->getEndDateTime();
if ($dtend) {
$properties[] = $this->newDateTimeProperty(
'DTEND',
$event->getEndDateTime());
}
$name = $event->getName();
if (strlen($name)) {
$properties[] = $this->newTextProperty(
'SUMMARY',
$name);
}
$description = $event->getDescription();
if (strlen($description)) {
$properties[] = $this->newTextProperty(
'DESCRIPTION',
$description);
}
$organizer = $event->getOrganizer();
if ($organizer) {
$properties[] = $this->newUserProperty(
'ORGANIZER',
$organizer);
}
$attendees = $event->getAttendees();
if ($attendees) {
foreach ($attendees as $attendee) {
$properties[] = $this->newUserProperty(
'ATTENDEE',
$attendee);
}
}
$rrule = $event->getRecurrenceRule();
if ($rrule) {
$properties[] = $this->newRRULEProperty(
'RRULE',
$rrule);
}
$recurrence_id = $event->getRecurrenceID();
if ($recurrence_id) {
$properties[] = $this->newTextProperty(
'RECURRENCE-ID',
$recurrence_id);
}
$exdates = $event->getRecurrenceExceptions();
if ($exdates) {
$properties[] = $this->newDateTimesProperty(
'EXDATE',
$exdates);
}
$rdates = $event->getRecurrenceDates();
if ($rdates) {
$properties[] = $this->newDateTimesProperty(
'RDATE',
$rdates);
}
return $properties;
}
private function newTextProperty(
$name,
$value,
array $parameters = array()) {
$map = array(
'\\' => '\\\\',
',' => '\\,',
"\n" => '\\n',
);
$value = (array)$value;
foreach ($value as $k => $v) {
$v = str_replace(array_keys($map), array_values($map), $v);
$value[$k] = $v;
}
$value = implode(',', $value);
return $this->newProperty($name, $value, $parameters);
}
private function newDateTimeProperty(
$name,
PhutilCalendarDateTime $value,
array $parameters = array()) {
return $this->newDateTimesProperty($name, array($value), $parameters);
}
private function newDateTimesProperty(
$name,
array $values,
array $parameters = array()) {
assert_instances_of($values, 'PhutilCalendarDateTime');
if (head($values)->getIsAllDay()) {
$parameters[] = array(
'name' => 'VALUE',
'values' => array(
'DATE',
),
);
}
$datetimes = array();
foreach ($values as $value) {
$datetimes[] = $value->getISO8601();
}
$datetimes = implode(';', $datetimes);
return $this->newProperty($name, $datetimes, $parameters);
}
private function newUserProperty(
$name,
PhutilCalendarUserNode $value,
array $parameters = array()) {
$parameters[] = array(
'name' => 'CN',
'values' => array(
$value->getName(),
),
);
$partstat = null;
switch ($value->getStatus()) {
case PhutilCalendarUserNode::STATUS_INVITED:
$partstat = 'NEEDS-ACTION';
break;
case PhutilCalendarUserNode::STATUS_ACCEPTED:
$partstat = 'ACCEPTED';
break;
case PhutilCalendarUserNode::STATUS_DECLINED:
$partstat = 'DECLINED';
break;
}
if ($partstat !== null) {
$parameters[] = array(
'name' => 'PARTSTAT',
'values' => array(
$partstat,
),
);
}
// TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it
// isn't clear if these are important to external programs or not.
return $this->newProperty($name, $value->getURI(), $parameters);
}
private function newRRULEProperty(
$name,
PhutilCalendarRecurrenceRule $rule,
array $parameters = array()) {
$value = $rule->toRRULE();
return $this->newProperty($name, $value, $parameters);
}
private function newProperty(
$name,
$value,
array $parameters = array()) {
$map = array(
'^' => '^^',
"\n" => '^n',
'"' => "^'",
);
$writable_params = array();
foreach ($parameters as $k => $parameter) {
$value_list = array();
foreach ($parameter['values'] as $v) {
$v = str_replace(array_keys($map), array_values($map), $v);
// If the parameter value isn't a very simple one, quote it.
// RFC5545 says that we MUST quote it if it has a colon, a semicolon,
// or a comma, and that we MUST quote it if it's a URI.
if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) {
$v = '"'.$v.'"';
}
$value_list[] = $v;
}
$writable_params[] = array(
'name' => $parameter['name'],
'value' => implode(',', $value_list),
);
}
return array(
'name' => $name,
'value' => $value,
'parameters' => $writable_params,
);
}
}

View file

@ -0,0 +1,341 @@
<?php
final class PhutilICSParserTestCase extends PhutilTestCase {
public function testICSParser() {
$event = $this->parseICSSingleEvent('simple.ics');
$this->assertEqual(
array(
array(
'name' => 'CREATED',
'parameters' => array(),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160908T172702Z',
),
'raw' => '20160908T172702Z',
),
),
array(
'name' => 'UID',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'1CEB57AF-0C9C-402D-B3BD-D75BD4843F68',
),
'raw' => '1CEB57AF-0C9C-402D-B3BD-D75BD4843F68',
),
),
array(
'name' => 'DTSTART',
'parameters' => array(
array(
'name' => 'TZID',
'values' => array(
array(
'value' => 'America/Los_Angeles',
'quoted' => false,
),
),
),
),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160915T090000',
),
'raw' => '20160915T090000',
),
),
array(
'name' => 'DTEND',
'parameters' => array(
array(
'name' => 'TZID',
'values' => array(
array(
'value' => 'America/Los_Angeles',
'quoted' => false,
),
),
),
),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160915T100000',
),
'raw' => '20160915T100000',
),
),
array(
'name' => 'SUMMARY',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'Simple Event',
),
'raw' => 'Simple Event',
),
),
array(
'name' => 'DESCRIPTION',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'This is a simple event.',
),
'raw' => 'This is a simple event.',
),
),
),
$event->getAttribute('ics.properties'));
$this->assertEqual(
'Simple Event',
$event->getName());
$this->assertEqual(
'This is a simple event.',
$event->getDescription());
$this->assertEqual(
1473955200,
$event->getStartDateTime()->getEpoch());
$this->assertEqual(
1473955200 + phutil_units('1 hour in seconds'),
$event->getEndDateTime()->getEpoch());
}
public function testICSOddTimezone() {
$event = $this->parseICSSingleEvent('zimbra-timezone.ics');
$start = $event->getStartDateTime();
$this->assertEqual(
'20170303T140000Z',
$start->getISO8601());
}
public function testICSFloatingTime() {
// This tests "floating" event times, which have no absolute time and are
// supposed to be interpreted using the viewer's timezone. It also uses
// a duration, and the duration needs to float along with the viewer
// timezone.
$event = $this->parseICSSingleEvent('floating.ics');
$start = $event->getStartDateTime();
$caught = null;
try {
$start->getEpoch();
} catch (Exception $ex) {
$caught = $ex;
}
$this->assertTrue(
($caught instanceof Exception),
pht('Expected exception for floating time with no viewer timezone.'));
$newyears_utc = strtotime('2015-01-01 00:00:00 UTC');
$this->assertEqual(1420070400, $newyears_utc);
$start->setViewerTimezone('UTC');
$this->assertEqual(
$newyears_utc,
$start->getEpoch());
$start->setViewerTimezone('America/Los_Angeles');
$this->assertEqual(
$newyears_utc + phutil_units('8 hours in seconds'),
$start->getEpoch());
$start->setViewerTimezone('America/New_York');
$this->assertEqual(
$newyears_utc + phutil_units('5 hours in seconds'),
$start->getEpoch());
$end = $event->getEndDateTime();
$end->setViewerTimezone('UTC');
$this->assertEqual(
$newyears_utc + phutil_units('24 hours in seconds'),
$end->getEpoch());
$end->setViewerTimezone('America/Los_Angeles');
$this->assertEqual(
$newyears_utc + phutil_units('32 hours in seconds'),
$end->getEpoch());
$end->setViewerTimezone('America/New_York');
$this->assertEqual(
$newyears_utc + phutil_units('29 hours in seconds'),
$end->getEpoch());
}
public function testICSVALARM() {
$event = $this->parseICSSingleEvent('valarm.ics');
// For now, we parse but ignore VALARM sections. This test just makes
// sure they survive parsing.
$start_epoch = strtotime('2016-10-19 22:00:00 UTC');
$this->assertEqual(1476914400, $start_epoch);
$this->assertEqual(
$start_epoch,
$event->getStartDateTime()->getEpoch());
}
public function testICSDuration() {
$event = $this->parseICSSingleEvent('duration.ics');
// Raw value is "20160719T095722Z".
$start_epoch = strtotime('2016-07-19 09:57:22 UTC');
$this->assertEqual(1468922242, $start_epoch);
// Raw value is "P1DT17H4M23S".
$duration =
phutil_units('1 day in seconds') +
phutil_units('17 hours in seconds') +
phutil_units('4 minutes in seconds') +
phutil_units('23 seconds in seconds');
$this->assertEqual(
$start_epoch,
$event->getStartDateTime()->getEpoch());
$this->assertEqual(
$start_epoch + $duration,
$event->getEndDateTime()->getEpoch());
}
public function testICSWeeklyEvent() {
$event = $this->parseICSSingleEvent('weekly.ics');
$start = $event->getStartDateTime();
$start->setViewerTimezone('UTC');
$rrule = $event->getRecurrenceRule()
->setStartDateTime($start);
$rset = id(new PhutilCalendarRecurrenceSet())
->addSource($rrule);
$result = $rset->getEventsBetween(null, null, 3);
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150811'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150818'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150825'),
);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Weekly recurring event.'));
}
public function testICSParserErrors() {
$map = array(
'err-missing-end.ics' => PhutilICSParser::PARSE_MISSING_END,
'err-bad-base64.ics' => PhutilICSParser::PARSE_BAD_BASE64,
'err-bad-boolean.ics' => PhutilICSParser::PARSE_BAD_BOOLEAN,
'err-extra-end.ics' => PhutilICSParser::PARSE_EXTRA_END,
'err-initial-unfold.ics' => PhutilICSParser::PARSE_INITIAL_UNFOLD,
'err-malformed-double-quote.ics' =>
PhutilICSParser::PARSE_MALFORMED_DOUBLE_QUOTE,
'err-malformed-parameter.ics' =>
PhutilICSParser::PARSE_MALFORMED_PARAMETER_NAME,
'err-malformed-property.ics' =>
PhutilICSParser::PARSE_MALFORMED_PROPERTY,
'err-missing-value.ics' => PhutilICSParser::PARSE_MISSING_VALUE,
'err-mixmatched-sections.ics' =>
PhutilICSParser::PARSE_MISMATCHED_SECTIONS,
'err-root-property.ics' => PhutilICSParser::PARSE_ROOT_PROPERTY,
'err-unescaped-backslash.ics' =>
PhutilICSParser::PARSE_UNESCAPED_BACKSLASH,
'err-unexpected-text.ics' => PhutilICSParser::PARSE_UNEXPECTED_TEXT,
'err-multiple-parameters.ics' =>
PhutilICSParser::PARSE_MULTIPLE_PARAMETERS,
'err-empty-datetime.ics' =>
PhutilICSParser::PARSE_EMPTY_DATETIME,
'err-many-datetime.ics' =>
PhutilICSParser::PARSE_MANY_DATETIME,
'err-bad-datetime.ics' =>
PhutilICSParser::PARSE_BAD_DATETIME,
'err-empty-duration.ics' =>
PhutilICSParser::PARSE_EMPTY_DURATION,
'err-many-duration.ics' =>
PhutilICSParser::PARSE_MANY_DURATION,
'err-bad-duration.ics' =>
PhutilICSParser::PARSE_BAD_DURATION,
'simple.ics' => null,
'good-boolean.ics' => null,
'multiple-vcalendars.ics' => null,
);
foreach ($map as $test_file => $expect) {
$caught = null;
try {
$this->parseICSDocument($test_file);
} catch (PhutilICSParserException $ex) {
$caught = $ex;
}
if ($expect === null) {
$this->assertTrue(
($caught === null),
pht(
'Expected no exception parsing "%s", got: %s',
$test_file,
(string)$ex));
} else {
if ($caught) {
$code = $ex->getParserFailureCode();
$explain = pht(
'Expected one exception parsing "%s", got a different '.
'one: %s',
$test_file,
(string)$ex);
} else {
$code = null;
$explain = pht(
'Expected exception parsing "%s", got none.',
$test_file);
}
$this->assertEqual($expect, $code, $explain);
}
}
}
private function parseICSSingleEvent($name) {
$root = $this->parseICSDocument($name);
$documents = $root->getDocuments();
$this->assertEqual(1, count($documents));
$document = head($documents);
$events = $document->getEvents();
$this->assertEqual(1, count($events));
return head($events);
}
private function parseICSDocument($name) {
$path = dirname(__FILE__).'/data/'.$name;
$data = Filesystem::readFile($path);
return id(new PhutilICSParser())
->parseICSData($data);
}
}

View file

@ -0,0 +1,144 @@
<?php
final class PhutilICSWriterTestCase extends PhutilTestCase {
public function testICSWriterTeaTime() {
$teas = array(
'earl grey tea',
'English breakfast tea',
'black tea',
'green tea',
't-rex',
'oolong tea',
'mint tea',
'tea with milk',
);
$teas = implode(', ', $teas);
$event = id(new PhutilCalendarEventNode())
->setUID('tea-time')
->setName('Tea Time')
->setDescription(
"Tea and, perhaps, crumpets.\n".
"Your presence is requested!\n".
"This is a long list of types of tea to test line wrapping: {$teas}.")
->setCreatedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z'))
->setModifiedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z'))
->setStartDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T150000Z'))
->setEndDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T160000Z'));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-tea-time.ics', $ics_data);
}
public function testICSWriterChristmas() {
$start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20001225T000000Z');
$end = PhutilCalendarAbsoluteDateTime::newFromISO8601('20001226T000000Z');
$rrule = id(new PhutilCalendarRecurrenceRule())
->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY)
->setByMonth(array(12))
->setByMonthDay(array(25));
$event = id(new PhutilCalendarEventNode())
->setUID('recurring-christmas')
->setName('Christmas')
->setDescription('Festival holiday first occurring in the year 2000.')
->setStartDateTime($start)
->setEndDateTime($end)
->setCreatedDateTime($start)
->setModifiedDateTime($start)
->setRecurrenceRule($rrule)
->setRecurrenceExceptions(
array(
// In 2007, Christmas was cancelled.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20071225T000000Z'),
))
->setRecurrenceDates(
array(
// We had an extra early Christmas in 2009.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20091125T000000Z'),
));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-recurring-christmas.ics', $ics_data);
}
public function testICSWriterAllDay() {
$event = id(new PhutilCalendarEventNode())
->setUID('christmas-day')
->setName('Christmas 2016')
->setDescription('A minor religious holiday.')
->setCreatedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z'))
->setModifiedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z'))
->setStartDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161225'))
->setEndDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161226'));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-christmas.ics', $ics_data);
}
public function testICSWriterUsers() {
$event = id(new PhutilCalendarEventNode())
->setUID('office-party')
->setName('Office Party')
->setCreatedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z'))
->setModifiedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z'))
->setStartDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T200000Z'))
->setEndDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T230000Z'))
->setOrganizer(
id(new PhutilCalendarUserNode())
->setName('Big Boss')
->setURI('mailto:big.boss@example.com'))
->addAttendee(
id(new PhutilCalendarUserNode())
->setName('Milton')
->setStatus(PhutilCalendarUserNode::STATUS_INVITED)
->setURI('mailto:milton@example.com'))
->addAttendee(
id(new PhutilCalendarUserNode())
->setName('Nancy')
->setStatus(PhutilCalendarUserNode::STATUS_ACCEPTED)
->setURI('mailto:nancy@example.com'));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-office-party.ics', $ics_data);
}
private function writeICSSingleEvent(PhutilCalendarEventNode $event) {
$calendar = id(new PhutilCalendarDocumentNode())
->appendChild($event);
$root = id(new PhutilCalendarRootNode())
->appendChild($calendar);
return $this->writeICS($root);
}
private function writeICS(PhutilCalendarRootNode $root) {
return id(new PhutilICSWriter())
->writeICSDocument($root);
}
private function assertICS($name, $actual) {
$path = dirname(__FILE__).'/data/'.$name;
$data = Filesystem::readFile($path);
$this->assertEqual($data, $actual, pht('ICS: %s', $name));
}
}

View file

@ -0,0 +1,8 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20160719T095722Z
DURATION:P1DT17H4M23S
SUMMARY:Duration Event
DESCRIPTION:This is an event with a complex duration.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DATA;VALUE=BINARY;ENCODING=BASE64:<QUACK! QUACK!>
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DUCK;VALUE=BOOLEAN:QUACK
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:quack
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DURATION:quack
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DURATION:
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1 @@
END:VCALENDAR

View file

@ -0,0 +1,2 @@
BEGIN:VCALENDAR
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
A;B="C:D
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
A;B:C
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
PEANUTBUTTER&JELLY:sandwich
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20130101,20130101
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DURATION:P1W,P2W
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,2 @@
BEGIN:VCALENDAR
BEGIN:VEVENT

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
TRIANGLE;color=red
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,4 @@
BEGIN:A
BEGIN:B
END:A
END:B

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;TZID=A,B:20160915T090000
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1 @@
NAME:value

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
STORY:The duck coughed up an unescaped backslash: \
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
SQUARE;color=red"
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,8 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20150101T000000
DURATION:P1D
SUMMARY:New Year's 2015
DESCRIPTION:This is an event with a floating start time.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DUCK;VALUE=BOOLEAN:TRUE
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,4 @@
BEGIN:VCALENDAR
END:VCALENDAR
BEGIN:VCALENDAR
END:VCALENDAR

View file

@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
CREATED:20160908T172702Z
UID:1CEB57AF-0C9C-402D-B3BD-D75BD4843F68
DTSTART;TZID=America/Los_Angeles:20160915T090000
DTEND;TZID=America/Los_Angeles:20160915T100000
SUMMARY:Simple Event
DESCRIPTION:This is a simple event.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
CREATED:20161027T173727
DTSTAMP:20161027T173727
LAST-MODIFIED:20161027T173727
UID:aic4zm86mg
SUMMARY:alarm event
DTSTART;TZID=Europe/Berlin:20161020T000000
DTEND;TZID=Europe/Berlin:20161020T010000
BEGIN:VALARM
ACTION:AUDIO
TRIGGER:-PT15M
END:VALARM
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,14 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
TRANSP:OPAQUE
DTEND;VALUE=DATE:20150812
LAST-MODIFIED:20160822T130015Z
UID:4AE69E91-4A51-4B77-8849-85981E037A83
DTSTAMP:20161129T152151Z
SUMMARY:Weekly Event
DTSTART;VALUE=DATE:20150811
CREATED:20141109T163445Z
RRULE:FREQ=WEEKLY
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,13 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:christmas-day
CREATED:20160901T232425Z
DTSTAMP:20160901T232425Z
DTSTART;VALUE=DATE:20161225
DTEND;VALUE=DATE:20161226
SUMMARY:Christmas 2016
DESCRIPTION:A minor religious holiday.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,15 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:office-party
CREATED:20161001T120000Z
DTSTAMP:20161001T120000Z
DTSTART:20161215T200000Z
DTEND:20161215T230000Z
SUMMARY:Office Party
ORGANIZER;CN="Big Boss":mailto:big.boss@example.com
ATTENDEE;CN=Milton;PARTSTAT=NEEDS-ACTION:mailto:milton@example.com
ATTENDEE;CN=Nancy;PARTSTAT=ACCEPTED:mailto:nancy@example.com
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:recurring-christmas
CREATED:20001225T000000Z
DTSTAMP:20001225T000000Z
DTSTART:20001225T000000Z
DTEND:20001226T000000Z
SUMMARY:Christmas
DESCRIPTION:Festival holiday first occurring in the year 2000.
RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25
EXDATE:20071225T000000Z
RDATE:20091125T000000Z
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:tea-time
CREATED:20160915T070000Z
DTSTAMP:20160915T070000Z
DTSTART:20160916T150000Z
DTEND:20160916T160000Z
SUMMARY:Tea Time
DESCRIPTION:Tea and\, perhaps\, crumpets.\nYour presence is requested!\nThi
s is a long list of types of tea to test line wrapping: earl grey tea\, En
glish breakfast tea\, black tea\, green tea\, t-rex\, oolong tea\, mint te
a\, tea with milk.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
CREATED:20161104T220244Z
UID:zimbra-timezone
SUMMARY:Zimbra Timezone
DTSTART;TZID="(GMT-05.00) Auto-Detected":20170303T090000
DTSTAMP:20161104T220244Z
SEQUENCE:0
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,107 @@
<?php
final class PhutilLipsumContextFreeGrammar
extends PhutilContextFreeGrammar {
protected function getRules() {
return array(
'start' => array(
'[words].',
'[words].',
'[words].',
'[words]: [word], [word], [word] [word].',
'[words]; [lowerwords].',
'[words]!',
'[words], "[words]."',
'[words] ("[upperword] [upperword] [upperword]") [lowerwords].',
'[words]?',
),
'words' => array(
'[upperword] [lowerwords]',
),
'upperword' => array(
'Lorem',
'Ipsum',
'Dolor',
'Sit',
'Amet',
),
'lowerwords' => array(
'[word]',
'[word] [word]',
'[word] [word] [word]',
'[word] [word] [word] [word]',
'[word] [word] [word] [word] [word]',
'[word] [word] [word] [word] [word]',
'[word] [word] [word] [word] [word] [word]',
'[word] [word] [word] [word] [word] [word]',
),
'word' => array(
'ad',
'adipisicing',
'aliqua',
'aliquip',
'amet',
'anim',
'aute',
'cillum',
'commodo',
'consectetur',
'consequat',
'culpa',
'cupidatat',
'deserunt',
'do',
'dolor',
'dolore',
'duis',
'ea',
'eiusmod',
'elit',
'enim',
'esse',
'est',
'et',
'eu',
'ex',
'excepteur',
'exercitation',
'fugiat',
'id',
'in',
'incididunt',
'ipsum',
'irure',
'labore',
'laboris',
'laborum',
'lorem',
'magna',
'minim',
'mollit',
'nisi',
'non',
'nostrud',
'nulla',
'occaecat',
'officia',
'pariatur',
'proident',
'qui',
'quis',
'reprehenderit',
'sed',
'sint',
'sit',
'sunt',
'tempor',
'ullamco',
'ut',
'velit',
'veniam',
'voluptate',
),
);
}
}

View file

@ -0,0 +1,155 @@
<?php
final class PhutilRealNameContextFreeGrammar
extends PhutilContextFreeGrammar {
protected function getRules() {
return array(
'start' => array(
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]-[last]',
'[first] [middle] [last]',
'[first] "[nick]" [last]',
'[first] [particle] [particle] [particle]',
),
'first' => array(
'Mohamed',
'Youssef',
'Ahmed',
'Mahmoud',
'Mustafa',
'Fatma',
'Aya',
'Noam',
'Adam',
'Lucas',
'Noah',
'Jakub',
'Victor',
'Harry',
'Rasmus',
'Nathan',
'Emil',
'Charlie',
'Leon',
'Dylan',
'Alexander',
'Emma',
'Marie',
'Lea',
'Amelia',
'Hanna',
'Emily',
'Sofia',
'Julia',
'Santiago',
'Sebastian',
'Olivia',
'Madison',
'Isabella',
'Esther',
'Anya',
'Camila',
'Jack',
'Oliver',
),
'nick' => array(
'Buzz',
'Juggernaut',
'Haze',
'Hawk',
'Iceman',
'Killer',
'Apex',
'Ocelot',
),
'middle' => array(
'Rose',
'Grace',
'Jane',
'Louise',
'Jade',
'James',
'John',
'William',
'Thomas',
'Alexander',
),
'last' => array(
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'O\'[termlast]',
'Mc[termlast]',
),
'termlast' => array(
'Smith',
'Johnson',
'Williams',
'Jones',
'Brown',
'Davis',
'Miller',
'Wilson',
'Moore',
'Taylor',
'Anderson',
'Thomas',
'Jackson',
'White',
'Harris',
'Martin',
'Thompson',
'Garcia',
'Marinez',
'Robinson',
'Clark',
'Rodrigues',
'Lewis',
'Lee',
'Walker',
'Hall',
'Allen',
'Young',
'Hernandex',
'King',
'Wang',
'Li',
'Zhang',
'Liu',
'Chen',
'Yang',
'Huang',
'Zhao',
'Wu',
'Zhou',
'Xu',
'Sun',
'Ma',
),
'particle' => array(
'Wu',
'Xu',
'Ma',
'Li',
'Liu',
'Shao',
'Lin',
'Khan',
),
);
}
}

View file

@ -0,0 +1,254 @@
<?php
/**
* Generates valid context-free code for most programming languages that could
* pass as C. Except for PHP. But includes Java (mostly).
*/
abstract class PhutilCLikeCodeSnippetContextFreeGrammar
extends PhutilCodeSnippetContextFreeGrammar {
protected function buildRuleSet() {
return array(
$this->getStmtTerminationGrammarSet(),
$this->getVarNameGrammarSet(),
$this->getNullExprGrammarSet(),
$this->getNumberGrammarSet(),
$this->getExprGrammarSet(),
$this->getCondGrammarSet(),
$this->getLoopGrammarSet(),
$this->getStmtGrammarSet(),
$this->getAssignmentGrammarSet(),
$this->getArithExprGrammarSet(),
$this->getBoolExprGrammarSet(),
$this->getBoolValGrammarSet(),
$this->getTernaryExprGrammarSet(),
$this->getFuncNameGrammarSet(),
$this->getFuncCallGrammarSet(),
$this->getFuncCallParamGrammarSet(),
$this->getFuncDeclGrammarSet(),
$this->getFuncParamGrammarSet(),
$this->getFuncBodyGrammarSet(),
$this->getFuncReturnGrammarSet(),
);
}
protected function getStartGrammarSet() {
$start_grammar = parent::getStartGrammarSet();
$start_grammar['start'][] = '[funcdecl]';
return $start_grammar;
}
protected function getStmtTerminationGrammarSet() {
return $this->buildGrammarSet('term', array(';'));
}
protected function getFuncCallGrammarSet() {
return $this->buildGrammarSet('funccall',
array(
'[funcname]([funccallparam])',
));
}
protected function getFuncCallParamGrammarSet() {
return $this->buildGrammarSet('funccallparam',
array(
'',
'[expr]',
'[expr], [expr]',
));
}
protected function getFuncDeclGrammarSet() {
return $this->buildGrammarSet('funcdecl',
array(
'function [funcname]([funcparam]) '.
'{[funcbody, indent, block, trim=right]}',
));
}
protected function getFuncParamGrammarSet() {
return $this->buildGrammarSet('funcparam',
array(
'',
'[varname]',
'[varname], [varname]',
'[varname], [varname], [varname]',
));
}
protected function getFuncBodyGrammarSet() {
return $this->buildGrammarSet('funcbody',
array(
"[stmt]\n[stmt]\n[funcreturn]",
"[stmt]\n[stmt]\n[stmt]\n[funcreturn]",
"[stmt]\n[stmt]\n[stmt]\n[stmt]\n[funcreturn]",
));
}
protected function getFuncReturnGrammarSet() {
return $this->buildGrammarSet('funcreturn',
array(
'return [expr][term]',
'',
));
}
// Not really C, but put it here because of the curly braces and mostly shared
// among Java and PHP
protected function getClassDeclGrammarSet() {
return $this->buildGrammarSet('classdecl',
array(
'[classinheritancemod] class [classname] {[classbody, indent, block]}',
'class [classname] {[classbody, indent, block]}',
));
}
protected function getClassNameGrammarSet() {
return $this->buildGrammarSet('classname',
array(
'MuffinHouse',
'MuffinReader',
'MuffinAwesomizer',
'SuperException',
'Librarian',
'Book',
'Ball',
'BallOfCode',
'AliceAndBobsSharedSecret',
'FileInputStream',
'FileOutputStream',
'BufferedReader',
'BufferedWriter',
'Cardigan',
'HouseOfCards',
'UmbrellaClass',
'GenericThing',
));
}
protected function getClassBodyGrammarSet() {
return $this->buildGrammarSet('classbody',
array(
'[methoddecl]',
"[methoddecl]\n\n[methoddecl]",
"[propdecl]\n[propdecl]\n\n[methoddecl]\n\n[methoddecl]",
"[propdecl]\n[propdecl]\n[propdecl]\n\n[methoddecl]\n\n[methoddecl]".
"\n\n[methoddecl]",
));
}
protected function getVisibilityGrammarSet() {
return $this->buildGrammarSet('visibility',
array(
'private',
'protected',
'public',
));
}
protected function getClassInheritanceModGrammarSet() {
return $this->buildGrammarSet('classinheritancemod',
array(
'final',
'abstract',
));
}
// Keeping this separate so we won't give abstract methods a function body
protected function getMethodInheritanceModGrammarSet() {
return $this->buildGrammarSet('methodinheritancemod',
array(
'final',
));
}
protected function getMethodDeclGrammarSet() {
return $this->buildGrammarSet('methoddecl',
array(
'[visibility] [methodfuncdecl]',
'[visibility] [methodfuncdecl]',
'[methodinheritancemod] [visibility] [methodfuncdecl]',
'[abstractmethoddecl]',
));
}
protected function getMethodFuncDeclGrammarSet() {
return $this->buildGrammarSet('methodfuncdecl',
array(
'function [funcname]([funcparam]) '.
'{[methodbody, indent, block, trim=right]}',
));
}
protected function getMethodBodyGrammarSet() {
return $this->buildGrammarSet('methodbody',
array(
"[methodstmt]\n[methodbody]",
"[methodstmt]\n[funcreturn]",
));
}
protected function getMethodStmtGrammarSet() {
$stmts = $this->getStmtGrammarSet();
return $this->buildGrammarSet('methodstmt',
array_merge(
$stmts['stmt'],
array(
'[methodcall][term]',
)));
}
protected function getMethodCallGrammarSet() {
// Java/JavaScript
return $this->buildGrammarSet('methodcall',
array(
'this.[funccall]',
'[varname].[funccall]',
'[classname].[funccall]',
));
}
protected function getAbstractMethodDeclGrammarSet() {
return $this->buildGrammarSet('abstractmethoddecl',
array(
'abstract function [funcname]([funcparam])[term]',
));
}
protected function getPropDeclGrammarSet() {
return $this->buildGrammarSet('propdecl',
array(
'[visibility] [varname][term]',
));
}
protected function getClassRuleSets() {
return array(
$this->getClassInheritanceModGrammarSet(),
$this->getMethodInheritanceModGrammarSet(),
$this->getClassDeclGrammarSet(),
$this->getClassNameGrammarSet(),
$this->getClassBodyGrammarSet(),
$this->getMethodDeclGrammarSet(),
$this->getMethodFuncDeclGrammarSet(),
$this->getMethodBodyGrammarSet(),
$this->getMethodStmtGrammarSet(),
$this->getMethodCallGrammarSet(),
$this->getAbstractMethodDeclGrammarSet(),
$this->getPropDeclGrammarSet(),
$this->getVisibilityGrammarSet(),
);
}
public function generateClass() {
$rules = array_merge($this->getRules(), $this->getClassRuleSets());
$rules['start'] = array('[classdecl]');
$count = 0;
return $this->applyRules('[start]', $count, $rules);
}
}

View file

@ -0,0 +1,205 @@
<?php
/**
* Generates non-sense code snippets according to context-free rules, respecting
* indentation etc.
*
* Also provides a common ruleset shared among many mainstream programming
* languages (that is, not Lisp).
*/
abstract class PhutilCodeSnippetContextFreeGrammar
extends PhutilContextFreeGrammar {
public function generate() {
// A trailing newline is favorable for source code
return trim(parent::generate())."\n";
}
final protected function getRules() {
return array_merge(
$this->getStartGrammarSet(),
$this->getStmtGrammarSet(),
array_mergev($this->buildRuleSet()));
}
abstract protected function buildRuleSet();
protected function buildGrammarSet($name, array $set) {
return array(
$name => $set,
);
}
protected function getStartGrammarSet() {
return $this->buildGrammarSet('start',
array(
"[stmt]\n[stmt]",
"[stmt]\n[stmt]\n[stmt]",
"[stmt]\n[stmt]\n[stmt]\n[stmt]",
));
}
protected function getStmtGrammarSet() {
return $this->buildGrammarSet('stmt',
array(
'[assignment][term]',
'[assignment][term]',
'[assignment][term]',
'[assignment][term]',
'[funccall][term]',
'[funccall][term]',
'[funccall][term]',
'[funccall][term]',
'[cond]',
'[loop]',
));
}
protected function getFuncNameGrammarSet() {
return $this->buildGrammarSet('funcname',
array(
'do_something',
'nonempty',
'noOp',
'call_user_func',
'getenv',
'render',
'super',
'derpify',
'awesomize',
'equals',
'run',
'flee',
'fight',
'notify',
'listen',
'calculate',
'aim',
'open',
));
}
protected function getVarNameGrammarSet() {
return $this->buildGrammarSet('varname',
array(
'is_something',
'object',
'name',
'token',
'label',
'piece_of_the_pie',
'type',
'state',
'param',
'action',
'key',
'timeout',
'result',
));
}
protected function getNullExprGrammarSet() {
return $this->buildGrammarSet('null', array('null'));
}
protected function getNumberGrammarSet() {
return $this->buildGrammarSet('number',
array(
mt_rand(-1, 100),
mt_rand(-100, 1000),
mt_rand(-1000, 5000),
mt_rand(0, 1).'.'.mt_rand(1, 1000),
mt_rand(0, 50).'.'.mt_rand(1, 1000),
));
}
protected function getExprGrammarSet() {
return $this->buildGrammarSet('expr',
array(
'[null]',
'[number]',
'[number]',
'[varname]',
'[varname]',
'[boolval]',
'[boolval]',
'[boolexpr]',
'[boolexpr]',
'[funccall]',
'[arithexpr]',
'[arithexpr]',
// Some random strings
'"'.Filesystem::readRandomCharacters(4).'"',
'"'.Filesystem::readRandomCharacters(5).'"',
));
}
protected function getBoolExprGrammarSet() {
return $this->buildGrammarSet('boolexpr',
array(
'[varname]',
'![varname]',
'[varname] == [boolval]',
'[varname] != [boolval]',
'[ternary]',
));
}
protected function getBoolValGrammarSet() {
return $this->buildGrammarSet('boolval',
array(
'true',
'false',
));
}
protected function getArithExprGrammarSet() {
return $this->buildGrammarSet('arithexpr',
array(
'[varname]++',
'++[varname]',
'[varname] + [number]',
'[varname]--',
'--[varname]',
'[varname] - [number]',
));
}
protected function getAssignmentGrammarSet() {
return $this->buildGrammarSet('assignment',
array(
'[varname] = [expr]',
'[varname] = [arithexpr]',
'[varname] += [expr]',
));
}
protected function getCondGrammarSet() {
return $this->buildGrammarSet('cond',
array(
'if ([boolexpr]) {[stmt, indent, block]}',
'if ([boolexpr]) {[stmt, indent, block]} else {[stmt, indent, block]}',
));
}
protected function getLoopGrammarSet() {
return $this->buildGrammarSet('loop',
array(
'while ([boolexpr]) {[stmt, indent, block]}',
'do {[stmt, indent, block]} while ([boolexpr])[term]',
'for ([assignment]; [boolexpr]; [expr]) {[stmt, indent, block]}',
));
}
protected function getTernaryExprGrammarSet() {
return $this->buildGrammarSet('ternary',
array(
'[boolexpr] ? [expr] : [expr]',
));
}
protected function getStmtTerminationGrammarSet() {
return $this->buildGrammarSet('term', array(''));
}
}

View file

@ -0,0 +1,184 @@
<?php
final class PhutilJavaCodeSnippetContextFreeGrammar
extends PhutilCLikeCodeSnippetContextFreeGrammar {
protected function buildRuleSet() {
$parent_ruleset = parent::buildRuleSet();
$rulesset = array_merge($parent_ruleset, $this->getClassRuleSets());
$rulesset[] = $this->getTypeNameGrammarSet();
$rulesset[] = $this->getNamespaceDeclGrammarSet();
$rulesset[] = $this->getNamespaceNameGrammarSet();
$rulesset[] = $this->getImportGrammarSet();
$rulesset[] = $this->getMethodReturnTypeGrammarSet();
$rulesset[] = $this->getMethodNameGrammarSet();
$rulesset[] = $this->getVarDeclGrammarSet();
$rulesset[] = $this->getClassDerivGrammarSet();
return $rulesset;
}
protected function getStartGrammarSet() {
return $this->buildGrammarSet('start',
array(
'[import, block][nmspdecl, block][classdecl, block]',
));
}
protected function getClassDeclGrammarSet() {
return $this->buildGrammarSet('classdecl',
array(
'[classinheritancemod] [visibility] class [classname][classderiv] '.
'{[classbody, indent, block]}',
'[visibility] class [classname][classderiv] '.
'{[classbody, indent, block]}',
));
}
protected function getClassDerivGrammarSet() {
return $this->buildGrammarSet('classderiv',
array(
' extends [classname]',
'',
'',
));
}
protected function getTypeNameGrammarSet() {
return $this->buildGrammarSet('type',
array(
'int',
'boolean',
'char',
'short',
'long',
'float',
'double',
'[classname]',
'[type][]',
));
}
protected function getMethodReturnTypeGrammarSet() {
return $this->buildGrammarSet('methodreturn',
array(
'[type]',
'void',
));
}
protected function getNamespaceDeclGrammarSet() {
return $this->buildGrammarSet('nmspdecl',
array(
'package [nmspname][term]',
));
}
protected function getNamespaceNameGrammarSet() {
return $this->buildGrammarSet('nmspname',
array(
'java.lang',
'java.io',
'com.example.proj.std',
'derp.example.www',
));
}
protected function getImportGrammarSet() {
return $this->buildGrammarSet('import',
array(
'import [nmspname][term]',
'import [nmspname].*[term]',
'import [nmspname].[classname][term]',
));
}
protected function getExprGrammarSet() {
$expr = parent::getExprGrammarSet();
$expr['expr'][] = 'new [classname]([funccallparam])';
$expr['expr'][] = '[methodcall]';
$expr['expr'][] = '[methodcall]';
$expr['expr'][] = '[methodcall]';
$expr['expr'][] = '[methodcall]';
// Add some 'char's
for ($ii = 0; $ii < 2; $ii++) {
$expr['expr'][] = "'".Filesystem::readRandomCharacters(1)."'";
}
return $expr;
}
protected function getStmtGrammarSet() {
$stmt = parent::getStmtGrammarSet();
$stmt['stmt'][] = '[vardecl]';
$stmt['stmt'][] = '[vardecl]';
// `try` to `throw` a `Ball`!
$stmt['stmt'][] = 'throw [classname][term]';
return $stmt;
}
protected function getPropDeclGrammarSet() {
return $this->buildGrammarSet('propdecl',
array(
'[visibility] [type] [varname][term]',
));
}
protected function getVarDeclGrammarSet() {
return $this->buildGrammarSet('vardecl',
array(
'[type] [varname][term]',
'[type] [assignment][term]',
));
}
protected function getFuncNameGrammarSet() {
return $this->buildGrammarSet('funcname',
array(
'[methodname]',
'[classname].[methodname]',
// This is just silly (too much recursion)
// '[classname].[funcname]',
// Don't do this for now, it just clutters up output (thanks to rec.)
// '[nmspname].[classname].[methodname]',
));
}
// Renamed from `funcname`
protected function getMethodNameGrammarSet() {
$funcnames = head(parent::getFuncNameGrammarSet());
return $this->buildGrammarSet('methodname', $funcnames);
}
protected function getMethodFuncDeclGrammarSet() {
return $this->buildGrammarSet('methodfuncdecl',
array(
'[methodreturn] [methodname]([funcparam]) '.
'{[methodbody, indent, block, trim=right]}',
));
}
protected function getFuncParamGrammarSet() {
return $this->buildGrammarSet('funcparam',
array(
'',
'[type] [varname]',
'[type] [varname], [type] [varname]',
'[type] [varname], [type] [varname], [type] [varname]',
));
}
protected function getAbstractMethodDeclGrammarSet() {
return $this->buildGrammarSet('abstractmethoddecl',
array(
'abstract [methodreturn] [methodname]([funcparam])[term]',
));
}
}

View file

@ -0,0 +1,57 @@
<?php
final class PhutilPHPCodeSnippetContextFreeGrammar
extends PhutilCLikeCodeSnippetContextFreeGrammar {
protected function buildRuleSet() {
return array_merge(parent::buildRuleSet(), $this->getClassRuleSets());
}
protected function getStartGrammarSet() {
$start_grammar = parent::getStartGrammarSet();
$start_grammar['start'][] = '[classdecl]';
$start_grammar['start'][] = '[classdecl]';
return $start_grammar;
}
protected function getExprGrammarSet() {
$expr = parent::getExprGrammarSet();
$expr['expr'][] = 'new [classname]([funccallparam])';
$expr['expr'][] = '[classname]::[funccall]';
return $expr;
}
protected function getVarNameGrammarSet() {
$varnames = parent::getVarNameGrammarSet();
foreach ($varnames as $vn_key => $vn_val) {
foreach ($vn_val as $vv_key => $vv_value) {
$varnames[$vn_key][$vv_key] = '$'.$vv_value;
}
}
return $varnames;
}
protected function getFuncNameGrammarSet() {
return $this->buildGrammarSet('funcname',
array_mergev(get_defined_functions()));
}
protected function getMethodCallGrammarSet() {
return $this->buildGrammarSet('methodcall',
array(
'$this->[funccall]',
'self::[funccall]',
'static::[funccall]',
'[varname]->[funccall]',
'[classname]::[funccall]',
));
}
}

View file

@ -0,0 +1,176 @@
<?php
/**
* Remarkup prevents several classes of text-processing problems by replacing
* tokens in the text as they are marked up. For example, if you write something
* like this:
*
* //D12//
*
* It is processed in several stages. First the "D12" matches and is replaced
* with a token, in the form of "<0x01><ID number><literal "Z">". The first
* byte, "<0x01>" is a single byte with value 1 that marks a token. If this is
* token ID "444", the text may now look like this:
*
* //<0x01>444Z//
*
* Now the italics match and are replaced, using the next token ID:
*
* <0x01>445Z
*
* When processing completes, all the tokens are replaced with their final
* equivalents. For example, token 444 is evaluated to:
*
* <a href="http://...">...</a>
*
* Then token 445 is evaluated:
*
* <em><0x01>444Z</em>
*
* ...and all tokens it contains are replaced:
*
* <em><a href="http://...">...</a></em>
*
* If we didn't do this, the italics rule could match the "//" in "http://",
* or any other number of processing mistakes could occur, some of which create
* security risks.
*
* This class generates keys, and stores the map of keys to replacement text.
*/
final class PhutilRemarkupBlockStorage extends Phobject {
const MAGIC_BYTE = "\1";
private $map = array();
private $index = 0;
public function store($text) {
$key = self::MAGIC_BYTE.(++$this->index).'Z';
$this->map[$key] = $text;
return $key;
}
public function restore($corpus, $text_mode = false) {
$map = $this->map;
if (!$text_mode) {
foreach ($map as $key => $content) {
$map[$key] = phutil_escape_html($content);
}
$corpus = phutil_escape_html($corpus);
}
// NOTE: Tokens may contain other tokens: for example, a table may have
// links inside it. So we can't do a single simple find/replace, because
// we need to find and replace child tokens inside the content of parent
// tokens.
// However, we know that rules which have child tokens must always store
// all their child tokens first, before they store their parent token: you
// have to pass the "store(text)" API a block of text with tokens already
// in it, so you must have created child tokens already.
// Thus, all child tokens will appear in the list before parent tokens, so
// if we start at the beginning of the list and replace all the tokens we
// find in each piece of content, we'll end up expanding all subtokens
// correctly.
$map[] = $corpus;
$seen = array();
foreach ($map as $key => $content) {
$seen[$key] = true;
// If the content contains no token magic, we don't need to replace
// anything.
if (strpos($content, self::MAGIC_BYTE) === false) {
continue;
}
$matches = null;
preg_match_all(
'/'.self::MAGIC_BYTE.'\d+Z/',
$content,
$matches,
PREG_OFFSET_CAPTURE);
$matches = $matches[0];
// See PHI1114. We're replacing all the matches in one pass because this
// is significantly faster than doing "substr_replace()" in a loop if the
// corpus is large and we have a large number of matches.
// Build a list of string pieces in "$parts" by interleaving the
// plain strings between each token and the replacement token text, then
// implode the whole thing when we're done.
$parts = array();
$pos = 0;
foreach ($matches as $next) {
$subkey = $next[0];
// If we've matched a token pattern but don't actually have any
// corresponding token, just skip this match. This should not be
// possible, and should perhaps be an error.
if (!isset($seen[$subkey])) {
if (!isset($map[$subkey])) {
throw new Exception(
pht(
'Matched token key "%s" while processing remarkup block, but '.
'this token does not exist in the token map.',
$subkey));
} else {
throw new Exception(
pht(
'Matched token key "%s" while processing remarkup block, but '.
'this token appears later in the list than the key being '.
'processed ("%s").',
$subkey,
$key));
}
}
$subpos = $next[1];
// If there were any non-token bytes since the last token, add them.
if ($subpos > $pos) {
$parts[] = substr($content, $pos, $subpos - $pos);
}
// Add the token replacement text.
$parts[] = $map[$subkey];
// Move the non-token cursor forward over the token.
$pos = $subpos + strlen($subkey);
}
// Add any leftover non-token bytes after the last token.
$parts[] = substr($content, $pos);
$content = implode('', $parts);
$map[$key] = $content;
}
$corpus = last($map);
if (!$text_mode) {
$corpus = phutil_safe_html($corpus);
}
return $corpus;
}
public function overwrite($key, $new_text) {
$this->map[$key] = $new_text;
return $this;
}
public function getMap() {
return $this->map;
}
public function setMap(array $map) {
$this->map = $map;
return $this;
}
}

View file

@ -0,0 +1,36 @@
<?php
abstract class PhutilRemarkupBlockInterpreter extends Phobject {
private $engine;
final public function setEngine($engine) {
$this->engine = $engine;
return $this;
}
final public function getEngine() {
return $this->engine;
}
/**
* @return string
*/
abstract public function getInterpreterName();
abstract public function markupContent($content, array $argv);
protected function markupError($string) {
if ($this->getEngine()->isTextMode()) {
return '('.$string.')';
} else {
return phutil_tag(
'div',
array(
'class' => 'remarkup-interpreter-error',
),
$string);
}
}
}

View file

@ -0,0 +1,170 @@
<?php
abstract class PhutilRemarkupBlockRule extends Phobject {
private $engine;
private $rules = array();
/**
* Determine the order in which blocks execute. Blocks with smaller priority
* numbers execute sooner than blocks with larger priority numbers. The
* default priority for blocks is `500`.
*
* Priorities are used to disambiguate syntax which can match multiple
* patterns. For example, ` - Lorem ipsum...` may be a code block or a
* list.
*
* @return int Priority at which this block should execute.
*/
public function getPriority() {
return 500;
}
final public function getPriorityVector() {
return id(new PhutilSortVector())
->addInt($this->getPriority())
->addString(get_class($this));
}
abstract public function markupText($text, $children);
/**
* This will get an array of unparsed lines and return the number of lines
* from the first array value that it can parse.
*
* @param array $lines
* @param int $cursor
*
* @return int
*/
abstract public function getMatchingLineCount(array $lines, $cursor);
protected function didMarkupText() {
return;
}
final public function setEngine(PhutilRemarkupEngine $engine) {
$this->engine = $engine;
$this->updateRules();
return $this;
}
final protected function getEngine() {
return $this->engine;
}
public function setMarkupRules(array $rules) {
assert_instances_of($rules, 'PhutilRemarkupRule');
$this->rules = $rules;
$this->updateRules();
return $this;
}
private function updateRules() {
$engine = $this->getEngine();
if ($engine) {
$this->rules = msort($this->rules, 'getPriority');
foreach ($this->rules as $rule) {
$rule->setEngine($engine);
}
}
return $this;
}
final public function getMarkupRules() {
return $this->rules;
}
final public function postprocess() {
$this->didMarkupText();
}
final protected function applyRules($text) {
foreach ($this->getMarkupRules() as $rule) {
$text = $rule->apply($text);
}
return $text;
}
public function supportsChildBlocks() {
return false;
}
public function extractChildText($text) {
throw new PhutilMethodNotImplementedException();
}
protected function renderRemarkupTable(array $out_rows) {
assert_instances_of($out_rows, 'array');
if ($this->getEngine()->isTextMode()) {
$lengths = array();
foreach ($out_rows as $r => $row) {
foreach ($row['content'] as $c => $cell) {
$text = $this->getEngine()->restoreText($cell['content']);
$lengths[$c][$r] = phutil_utf8_strlen($text);
}
}
$max_lengths = array_map('max', $lengths);
$out = array();
foreach ($out_rows as $r => $row) {
$headings = false;
foreach ($row['content'] as $c => $cell) {
$length = $max_lengths[$c] - $lengths[$c][$r];
$out[] = '| '.$cell['content'].str_repeat(' ', $length).' ';
if ($cell['type'] == 'th') {
$headings = true;
}
}
$out[] = "|\n";
if ($headings) {
foreach ($row['content'] as $c => $cell) {
$char = ($cell['type'] == 'th' ? '-' : ' ');
$out[] = '| '.str_repeat($char, $max_lengths[$c]).' ';
}
$out[] = "|\n";
}
}
return rtrim(implode('', $out), "\n");
}
if ($this->getEngine()->isHTMLMailMode()) {
$table_attributes = array(
'style' => 'border-collapse: separate;
border-spacing: 1px;
background: #d3d3d3;
margin: 12px 0;',
);
$cell_attributes = array(
'style' => 'background: #ffffff;
padding: 3px 6px;',
);
} else {
$table_attributes = array(
'class' => 'remarkup-table',
);
$cell_attributes = array();
}
$out = array();
$out[] = "\n";
foreach ($out_rows as $row) {
$cells = array();
foreach ($row['content'] as $cell) {
$cells[] = phutil_tag(
$cell['type'],
$cell_attributes,
$cell['content']);
}
$out[] = phutil_tag($row['type'], array(), $cells);
$out[] = "\n";
}
$table = phutil_tag('table', $table_attributes, $out);
return phutil_tag_div('remarkup-table-wrap', $table);
}
}

View file

@ -0,0 +1,252 @@
<?php
final class PhutilRemarkupCodeBlockRule extends PhutilRemarkupBlockRule {
public function getMatchingLineCount(array $lines, $cursor) {
$num_lines = 0;
$match_ticks = null;
if (preg_match('/^(\s{2,}).+/', $lines[$cursor])) {
$match_ticks = false;
} else if (preg_match('/^\s*(```)/', $lines[$cursor])) {
$match_ticks = true;
} else {
return $num_lines;
}
$num_lines++;
if ($match_ticks &&
preg_match('/^\s*(```)(.*)(```)\s*$/', $lines[$cursor])) {
return $num_lines;
}
$cursor++;
while (isset($lines[$cursor])) {
if ($match_ticks) {
if (preg_match('/```\s*$/', $lines[$cursor])) {
$num_lines++;
break;
}
$num_lines++;
} else {
if (strlen(trim($lines[$cursor]))) {
if (!preg_match('/^\s{2,}/', $lines[$cursor])) {
break;
}
}
$num_lines++;
}
$cursor++;
}
return $num_lines;
}
public function markupText($text, $children) {
if (preg_match('/^\s*```/', $text)) {
// If this is a ```-style block, trim off the backticks and any leading
// blank line.
$text = preg_replace('/^\s*```(\s*\n)?/', '', $text);
$text = preg_replace('/```\s*$/', '', $text);
}
$lines = explode("\n", $text);
while ($lines && !strlen(last($lines))) {
unset($lines[last_key($lines)]);
}
$options = array(
'counterexample' => false,
'lang' => null,
'name' => null,
'lines' => null,
);
$parser = new PhutilSimpleOptions();
$custom = $parser->parse(head($lines));
if ($custom) {
$valid = true;
foreach ($custom as $key => $value) {
if (!array_key_exists($key, $options)) {
$valid = false;
break;
}
}
if ($valid) {
array_shift($lines);
$options = $custom + $options;
}
}
// Normalize the text back to a 0-level indent.
$min_indent = 80;
foreach ($lines as $line) {
for ($ii = 0; $ii < strlen($line); $ii++) {
if ($line[$ii] != ' ') {
$min_indent = min($ii, $min_indent);
break;
}
}
}
$text = implode("\n", $lines);
if ($min_indent) {
$indent_string = str_repeat(' ', $min_indent);
$text = preg_replace('/^'.$indent_string.'/m', '', $text);
}
if ($this->getEngine()->isTextMode()) {
$out = array();
$header = array();
if ($options['counterexample']) {
$header[] = 'counterexample';
}
if ($options['name'] != '') {
$header[] = 'name='.$options['name'];
}
if ($header) {
$out[] = implode(', ', $header);
}
$text = preg_replace('/^/m', ' ', $text);
$out[] = $text;
return implode("\n", $out);
}
if (empty($options['lang'])) {
// If the user hasn't specified "lang=..." explicitly, try to guess the
// language. If we fail, fall back to configured defaults.
$lang = PhutilLanguageGuesser::guessLanguage($text);
if (!$lang) {
$lang = nonempty(
$this->getEngine()->getConfig('phutil.codeblock.language-default'),
'text');
}
$options['lang'] = $lang;
}
$code_body = $this->highlightSource($text, $options);
$name_header = null;
$block_style = null;
if ($this->getEngine()->isHTMLMailMode()) {
$map = $this->getEngine()->getConfig('phutil.codeblock.style-map');
if ($map) {
$raw_body = id(new PhutilPygmentizeParser())
->setMap($map)
->parse((string)$code_body);
$code_body = phutil_safe_html($raw_body);
}
$style_rules = array(
'padding: 6px 12px;',
'font-size: 13px;',
'font-weight: bold;',
'display: inline-block;',
'border-top-left-radius: 3px;',
'border-top-right-radius: 3px;',
'color: rgba(0,0,0,.75);',
);
if ($options['counterexample']) {
$style_rules[] = 'background: #f7e6e6';
} else {
$style_rules[] = 'background: rgba(71, 87, 120, 0.08);';
}
$header_attributes = array(
'style' => implode(' ', $style_rules),
);
$block_style = 'margin: 12px 0;';
} else {
$header_attributes = array(
'class' => 'remarkup-code-header',
);
}
if ($options['name']) {
$name_header = phutil_tag(
'div',
$header_attributes,
$options['name']);
}
$class = 'remarkup-code-block';
if ($options['counterexample']) {
$class = 'remarkup-code-block code-block-counterexample';
}
$attributes = array(
'class' => $class,
'style' => $block_style,
'data-code-lang' => $options['lang'],
'data-sigil' => 'remarkup-code-block',
);
return phutil_tag(
'div',
$attributes,
array($name_header, $code_body));
}
private function highlightSource($text, array $options) {
if ($options['counterexample']) {
$aux_class = ' remarkup-counterexample';
} else {
$aux_class = null;
}
$aux_style = null;
if ($this->getEngine()->isHTMLMailMode()) {
$aux_style = array(
'font: 11px/15px "Menlo", "Consolas", "Monaco", monospace;',
'padding: 12px;',
'margin: 0;',
);
if ($options['counterexample']) {
$aux_style[] = 'background: #f7e6e6;';
} else {
$aux_style[] = 'background: rgba(71, 87, 120, 0.08);';
}
$aux_style = implode(' ', $aux_style);
}
if ($options['lines']) {
// Put a minimum size on this because the scrollbar is otherwise
// unusable.
$height = max(6, (int)$options['lines']);
$aux_style = $aux_style
.' '
.'max-height: '
.(2 * $height)
.'em; overflow: auto;';
}
$engine = $this->getEngine()->getConfig('syntax-highlighter.engine');
if (!$engine) {
$engine = 'PhutilDefaultSyntaxHighlighterEngine';
}
$engine = newv($engine, array());
$engine->setConfig(
'pygments.enabled',
$this->getEngine()->getConfig('pygments.enabled'));
return phutil_tag(
'pre',
array(
'class' => 'remarkup-code'.$aux_class,
'style' => $aux_style,
),
PhutilSafeHTML::applyFunction(
'rtrim',
$engine->highlightSource($options['lang'], $text)));
}
}

View file

@ -0,0 +1,44 @@
<?php
final class PhutilRemarkupDefaultBlockRule extends PhutilRemarkupBlockRule {
public function getPriority() {
return 750;
}
public function getMatchingLineCount(array $lines, $cursor) {
return 1;
}
public function markupText($text, $children) {
$engine = $this->getEngine();
$text = trim($text);
$text = $this->applyRules($text);
if ($engine->isTextMode()) {
if (!$this->getEngine()->getConfig('preserve-linebreaks')) {
$text = preg_replace('/ *\n */', ' ', $text);
}
return $text;
}
if ($engine->getConfig('preserve-linebreaks')) {
$text = phutil_escape_html_newlines($text);
}
if (!strlen($text)) {
return null;
}
$default_attributes = $engine->getConfig('default.p.attributes');
if ($default_attributes) {
$attributes = $default_attributes;
} else {
$attributes = array();
}
return phutil_tag('p', $attributes, $text);
}
}

View file

@ -0,0 +1,162 @@
<?php
final class PhutilRemarkupHeaderBlockRule extends PhutilRemarkupBlockRule {
public function getMatchingLineCount(array $lines, $cursor) {
$num_lines = 0;
if (preg_match('/^(={1,5}|#{2,5}|# ).*+$/', $lines[$cursor])) {
$num_lines = 1;
} else {
if (isset($lines[$cursor + 1])) {
$line = $lines[$cursor].$lines[$cursor + 1];
if (preg_match('/^([^\n]+)\n[-=]{2,}\s*$/', $line)) {
$num_lines = 2;
$cursor++;
}
}
}
if ($num_lines) {
$cursor++;
while (isset($lines[$cursor]) && !strlen(trim($lines[$cursor]))) {
$num_lines++;
$cursor++;
}
}
return $num_lines;
}
const KEY_HEADER_TOC = 'headers.toc';
public function markupText($text, $children) {
$text = trim($text);
$lines = phutil_split_lines($text);
if (count($lines) > 1) {
$level = ($lines[1][0] == '=') ? 1 : 2;
$text = trim($lines[0]);
} else {
$level = 0;
for ($ii = 0; $ii < min(5, strlen($text)); $ii++) {
if ($text[$ii] == '=' || $text[$ii] == '#') {
++$level;
} else {
break;
}
}
$text = trim($text, ' =#');
}
$engine = $this->getEngine();
if ($engine->isTextMode()) {
$char = ($level == 1) ? '=' : '-';
return $text."\n".str_repeat($char, phutil_utf8_strlen($text));
}
$use_anchors = $engine->getConfig('header.generate-toc');
$anchor = null;
if ($use_anchors) {
$anchor = $this->generateAnchor($level, $text);
}
$text = phutil_tag(
'h'.($level + 1),
array(
'class' => 'remarkup-header',
),
array($anchor, $this->applyRules($text)));
return $text;
}
private function generateAnchor($level, $text) {
$anchor = strtolower($text);
$anchor = preg_replace('/[^a-z0-9]/', '-', $anchor);
$anchor = preg_replace('/--+/', '-', $anchor);
$anchor = trim($anchor, '-');
$anchor = substr($anchor, 0, 24);
$anchor = trim($anchor, '-');
$base = $anchor;
$key = self::KEY_HEADER_TOC;
$engine = $this->getEngine();
$anchors = $engine->getTextMetadata($key, array());
$suffix = 1;
while (!strlen($anchor) || isset($anchors[$anchor])) {
$anchor = $base.'-'.$suffix;
$anchor = trim($anchor, '-');
$suffix++;
}
// When a document contains a link inside a header, like this:
//
// = [[ http://wwww.example.com/ | example ]] =
//
// ...we want to generate a TOC entry with just "example", but link the
// header itself. We push the 'toc' state so all the link rules generate
// just names.
$engine->pushState('toc');
$text = $this->applyRules($text);
$text = $engine->restoreText($text);
$anchors[$anchor] = array($level, $text);
$engine->popState('toc');
$engine->setTextMetadata($key, $anchors);
return phutil_tag(
'a',
array(
'name' => $anchor,
),
'');
}
public static function renderTableOfContents(PhutilRemarkupEngine $engine) {
$key = self::KEY_HEADER_TOC;
$anchors = $engine->getTextMetadata($key, array());
if (count($anchors) < 2) {
// Don't generate a TOC if there are no headers, or if there's only
// one header (since such a TOC would be silly).
return null;
}
$depth = 0;
$toc = array();
foreach ($anchors as $anchor => $info) {
list($level, $name) = $info;
while ($depth < $level) {
$toc[] = hsprintf('<ul>');
$depth++;
}
while ($depth > $level) {
$toc[] = hsprintf('</ul>');
$depth--;
}
$toc[] = phutil_tag(
'li',
array(),
phutil_tag(
'a',
array(
'href' => '#'.$anchor,
),
$name));
}
while ($depth > 0) {
$toc[] = hsprintf('</ul>');
$depth--;
}
return phutil_implode_html("\n", $toc);
}
}

View file

@ -0,0 +1,37 @@
<?php
final class PhutilRemarkupHorizontalRuleBlockRule
extends PhutilRemarkupBlockRule {
/**
* This rule executes at priority `300`, so it can preempt the list block
* rule and claim blocks which begin `---`.
*/
public function getPriority() {
return 300;
}
public function getMatchingLineCount(array $lines, $cursor) {
$num_lines = 0;
$pattern = '/^\s*(?:_{3,}|\*\s?\*\s?\*(\s|\*)*|\-\s?\-\s?\-(\s|\-)*)$/';
if (preg_match($pattern, rtrim($lines[$cursor], "\n\r"))) {
$num_lines++;
$cursor++;
while (isset($lines[$cursor]) && !strlen(trim($lines[$cursor]))) {
$num_lines++;
$cursor++;
}
}
return $num_lines;
}
public function markupText($text, $children) {
if ($this->getEngine()->isTextMode()) {
return rtrim($text);
}
return phutil_tag('hr', array('class' => 'remarkup-hr'));
}
}

View file

@ -0,0 +1,13 @@
<?php
final class PhutilRemarkupInlineBlockRule extends PhutilRemarkupBlockRule {
public function getMatchingLineCount(array $lines, $cursor) {
return 1;
}
public function markupText($text, $children) {
return $this->applyRules($text);
}
}

View file

@ -0,0 +1,89 @@
<?php
final class PhutilRemarkupInterpreterBlockRule extends PhutilRemarkupBlockRule {
const START_BLOCK_PATTERN = '/^([\w]+)\s*(?:\(([^)]+)\)\s*)?{{{/';
const END_BLOCK_PATTERN = '/}}}\s*$/';
public function getMatchingLineCount(array $lines, $cursor) {
$num_lines = 0;
if (preg_match(self::START_BLOCK_PATTERN, $lines[$cursor])) {
$num_lines++;
while (isset($lines[$cursor])) {
if (preg_match(self::END_BLOCK_PATTERN, $lines[$cursor])) {
break;
}
$num_lines++;
$cursor++;
}
}
return $num_lines;
}
public function markupText($text, $children) {
$lines = explode("\n", $text);
$first_key = head_key($lines);
$last_key = last_key($lines);
while (trim($lines[$last_key]) === '') {
unset($lines[$last_key]);
$last_key = last_key($lines);
}
$matches = null;
preg_match(self::START_BLOCK_PATTERN, head($lines), $matches);
$argv = array();
if (isset($matches[2])) {
$argv = id(new PhutilSimpleOptions())->parse($matches[2]);
}
$interpreters = id(new PhutilClassMapQuery())
->setAncestorClass('PhutilRemarkupBlockInterpreter')
->execute();
foreach ($interpreters as $interpreter) {
$interpreter->setEngine($this->getEngine());
}
$lines[$first_key] = preg_replace(
self::START_BLOCK_PATTERN,
'',
$lines[$first_key]);
$lines[$last_key] = preg_replace(
self::END_BLOCK_PATTERN,
'',
$lines[$last_key]);
if (trim($lines[$first_key]) === '') {
unset($lines[$first_key]);
}
if (trim($lines[$last_key]) === '') {
unset($lines[$last_key]);
}
$content = implode("\n", $lines);
$interpreters = mpull($interpreters, null, 'getInterpreterName');
if (isset($interpreters[$matches[1]])) {
return $interpreters[$matches[1]]->markupContent($content, $argv);
}
$message = pht('No interpreter found: %s', $matches[1]);
if ($this->getEngine()->isTextMode()) {
return '('.$message.')';
}
return phutil_tag(
'div',
array(
'class' => 'remarkup-interpreter-error',
),
$message);
}
}

View file

@ -0,0 +1,567 @@
<?php
final class PhutilRemarkupListBlockRule extends PhutilRemarkupBlockRule {
/**
* This rule must apply before the Code block rule because it needs to
* win blocks which begin ` - Lorem ipsum`.
*/
public function getPriority() {
return 400;
}
public function getMatchingLineCount(array $lines, $cursor) {
$num_lines = 0;
$first_line = $cursor;
$is_one_line = false;
while (isset($lines[$cursor])) {
if (!$num_lines) {
if (preg_match(self::START_BLOCK_PATTERN, $lines[$cursor])) {
$num_lines++;
$cursor++;
$is_one_line = true;
continue;
}
} else {
if (preg_match(self::CONT_BLOCK_PATTERN, $lines[$cursor])) {
$num_lines++;
$cursor++;
$is_one_line = false;
continue;
}
// Allow lists to continue across multiple paragraphs, as long as lines
// are indented or a single empty line separates indented lines.
$this_empty = !strlen(trim($lines[$cursor]));
$this_indented = preg_match('/^ /', $lines[$cursor]);
$next_empty = true;
$next_indented = false;
if (isset($lines[$cursor + 1])) {
$next_empty = !strlen(trim($lines[$cursor + 1]));
$next_indented = preg_match('/^ /', $lines[$cursor + 1]);
}
if ($this_empty || $this_indented) {
if (($this_indented && !$this_empty) ||
($next_indented && !$next_empty)) {
$num_lines++;
$cursor++;
continue;
}
}
if ($this_empty) {
$num_lines++;
}
}
break;
}
// If this list only has one item in it, and the list marker is "#", and
// it's not the last line in the input, parse it as a header instead of a
// list. This produces better behavior for alternate Markdown headers.
if ($is_one_line) {
if (($first_line + $num_lines) < count($lines)) {
if (strncmp($lines[$first_line], '#', 1) === 0) {
return 0;
}
}
}
return $num_lines;
}
/**
* The maximum sub-list depth you can nest to. Avoids silliness and blowing
* the stack.
*/
const MAXIMUM_LIST_NESTING_DEPTH = 12;
const START_BLOCK_PATTERN = '@^\s*(?:[-*#]+|([1-9][0-9]*)[.)]|\[\D?\])\s+@';
const CONT_BLOCK_PATTERN = '@^\s*(?:[-*#]+|[0-9]+[.)]|\[\D?\])\s+@';
const STRIP_BLOCK_PATTERN = '@^\s*(?:[-*#]+|[0-9]+[.)])\s*@';
public function markupText($text, $children) {
$items = array();
$lines = explode("\n", $text);
// We allow users to delimit lists using either differing indentation
// levels:
//
// - a
// - b
//
// ...or differing numbers of item-delimiter characters:
//
// - a
// -- b
//
// If they use the second style but block-indent the whole list, we'll
// get the depth counts wrong for the first item. To prevent this,
// un-indent every item by the minimum indentation level for the whole
// block before we begin parsing.
$regex = self::START_BLOCK_PATTERN;
$min_space = PHP_INT_MAX;
foreach ($lines as $ii => $line) {
$matches = null;
if (preg_match($regex, $line)) {
$regex = self::CONT_BLOCK_PATTERN;
if (preg_match('/^(\s+)/', $line, $matches)) {
$space = strlen($matches[1]);
} else {
$space = 0;
}
$min_space = min($min_space, $space);
}
}
$regex = self::START_BLOCK_PATTERN;
if ($min_space) {
foreach ($lines as $key => $line) {
if (preg_match($regex, $line)) {
$regex = self::CONT_BLOCK_PATTERN;
$lines[$key] = substr($line, $min_space);
}
}
}
// The input text may have linewraps in it, like this:
//
// - derp derp derp derp
// derp derp derp derp
// - blarp blarp blarp blarp
//
// Group text lines together into list items, stored in $items. So the
// result in the above case will be:
//
// array(
// array(
// "- derp derp derp derp",
// " derp derp derp derp",
// ),
// array(
// "- blarp blarp blarp blarp",
// ),
// );
$item = array();
$starts_at = null;
$regex = self::START_BLOCK_PATTERN;
foreach ($lines as $line) {
$match = null;
if (preg_match($regex, $line, $match)) {
if (!$starts_at && !empty($match[1])) {
$starts_at = $match[1];
}
$regex = self::CONT_BLOCK_PATTERN;
if ($item) {
$items[] = $item;
$item = array();
}
}
$item[] = $line;
}
if ($item) {
$items[] = $item;
}
if (!$starts_at) {
$starts_at = 1;
}
// Process each item to normalize the text, remove line wrapping, and
// determine its depth (indentation level) and style (ordered vs unordered).
//
// We preserve consecutive linebreaks and interpret them as paragraph
// breaks.
//
// Given the above example, the processed array will look like:
//
// array(
// array(
// 'text' => 'derp derp derp derp derp derp derp derp',
// 'depth' => 0,
// 'style' => '-',
// ),
// array(
// 'text' => 'blarp blarp blarp blarp',
// 'depth' => 0,
// 'style' => '-',
// ),
// );
$has_marks = false;
foreach ($items as $key => $item) {
// Trim space around newlines, to strip trailing whitespace and formatting
// indentation.
$item = preg_replace('/ *(\n+) */', '\1', implode("\n", $item));
// Replace single newlines with a space. Preserve multiple newlines as
// paragraph breaks.
$item = preg_replace('/(?<!\n)\n(?!\n)/', ' ', $item);
$item = rtrim($item);
if (!strlen($item)) {
unset($items[$key]);
continue;
}
$matches = null;
if (preg_match('/^\s*([-*#]{2,})/', $item, $matches)) {
// Alternate-style indents; use number of list item symbols.
$depth = strlen($matches[1]) - 1;
} else if (preg_match('/^(\s+)/', $item, $matches)) {
// Markdown-style indents; use indent depth.
$depth = strlen($matches[1]);
} else {
$depth = 0;
}
if (preg_match('/^\s*(?:#|[0-9])/', $item)) {
$style = '#';
} else {
$style = '-';
}
// Strip leading indicators off the item.
$text = preg_replace(self::STRIP_BLOCK_PATTERN, '', $item);
// Look for "[]", "[ ]", "[*]", "[x]", etc., which we render as a
// checkbox. We don't render [1], [2], etc., as checkboxes, as these
// are often used as footnotes.
$mark = null;
$matches = null;
if (preg_match('/^\s*\[(\D?)\]\s*/', $text, $matches)) {
if (strlen(trim($matches[1]))) {
$mark = true;
} else {
$mark = false;
}
$has_marks = true;
$text = substr($text, strlen($matches[0]));
}
$items[$key] = array(
'text' => $text,
'depth' => $depth,
'style' => $style,
'mark' => $mark,
);
}
$items = array_values($items);
// Users can create a sub-list by indenting any deeper amount than the
// previous list, so these are both valid:
//
// - a
// - b
//
// - a
// - b
//
// In the former case, we'll have depths (0, 2). In the latter case, depths
// (0, 4). We don't actually care about how many spaces there are, only
// how many list indentation levels (that is, we want to map both of
// those cases to (0, 1), indicating "outermost list" and "first sublist").
//
// This is made more complicated because lists at two different indentation
// levels might be at the same list level:
//
// - a
// - b
// - c
// - d
//
// Here, 'b' and 'd' are at the same list level (2) but different indent
// levels (2, 4).
//
// Users can also create "staircases" like this:
//
// - a
// - b
// # c
//
// While this is silly, we'd like to render it as faithfully as possible.
//
// In order to do this, we convert the list of nodes into a tree,
// normalizing indentation levels and inserting dummy nodes as necessary to
// make the tree well-formed. See additional notes at buildTree().
//
// In the case above, the result is a tree like this:
//
// - <null>
// - <null>
// - a
// - b
// # c
$l = 0;
$r = count($items);
$tree = $this->buildTree($items, $l, $r, $cur_level = 0);
// We may need to open a list on a <null> node, but they do not have
// list style information yet. We need to propagate list style information
// backward through the tree. In the above example, the tree now looks
// like this:
//
// - <null (style=#)>
// - <null (style=-)>
// - a
// - b
// # c
$this->adjustTreeStyleInformation($tree);
// Finally, we have enough information to render the tree.
$out = $this->renderTree($tree, 0, $has_marks, $starts_at);
if ($this->getEngine()->isTextMode()) {
$out = implode('', $out);
$out = rtrim($out, "\n");
$out = preg_replace('/ +$/m', '', $out);
return $out;
}
return phutil_implode_html('', $out);
}
/**
* See additional notes in @{method:markupText}.
*/
private function buildTree(array $items, $l, $r, $cur_level) {
if ($l == $r) {
return array();
}
if ($cur_level > self::MAXIMUM_LIST_NESTING_DEPTH) {
// This algorithm is recursive and we don't need you blowing the stack
// with your oh-so-clever 50,000-item-deep list. Cap indentation levels
// at a reasonable number and just shove everything deeper up to this
// level.
$nodes = array();
for ($ii = $l; $ii < $r; $ii++) {
$nodes[] = array(
'level' => $cur_level,
'items' => array(),
) + $items[$ii];
}
return $nodes;
}
$min = $l;
for ($ii = $r - 1; $ii >= $l; $ii--) {
if ($items[$ii]['depth'] <= $items[$min]['depth']) {
$min = $ii;
}
}
$min_depth = $items[$min]['depth'];
$nodes = array();
if ($min != $l) {
$nodes[] = array(
'text' => null,
'level' => $cur_level,
'style' => null,
'mark' => null,
'items' => $this->buildTree($items, $l, $min, $cur_level + 1),
);
}
$last = $min;
for ($ii = $last + 1; $ii < $r; $ii++) {
if ($items[$ii]['depth'] == $min_depth) {
$nodes[] = array(
'level' => $cur_level,
'items' => $this->buildTree($items, $last + 1, $ii, $cur_level + 1),
) + $items[$last];
$last = $ii;
}
}
$nodes[] = array(
'level' => $cur_level,
'items' => $this->buildTree($items, $last + 1, $r, $cur_level + 1),
) + $items[$last];
return $nodes;
}
/**
* See additional notes in @{method:markupText}.
*/
private function adjustTreeStyleInformation(array &$tree) {
// The effect here is just to walk backward through the nodes at this level
// and apply the first style in the list to any empty nodes we inserted
// before it. As we go, also recurse down the tree.
$style = '-';
for ($ii = count($tree) - 1; $ii >= 0; $ii--) {
if ($tree[$ii]['style'] !== null) {
// This is the earliest node we've seen with style, so set the
// style to its style.
$style = $tree[$ii]['style'];
} else {
// This node has no style, so apply the current style.
$tree[$ii]['style'] = $style;
}
if ($tree[$ii]['items']) {
$this->adjustTreeStyleInformation($tree[$ii]['items']);
}
}
}
/**
* See additional notes in @{method:markupText}.
*/
private function renderTree(
array $tree,
$level,
$has_marks,
$starts_at = 1) {
$style = idx(head($tree), 'style');
$out = array();
if (!$this->getEngine()->isTextMode()) {
switch ($style) {
case '#':
$tag = 'ol';
break;
case '-':
$tag = 'ul';
break;
}
$start_attr = null;
if (ctype_digit($starts_at) && $starts_at > 1) {
$start_attr = hsprintf(' start="%d"', $starts_at);
}
if ($has_marks) {
$out[] = hsprintf(
'<%s class="remarkup-list remarkup-list-with-checkmarks"%s>',
$tag,
$start_attr);
} else {
$out[] = hsprintf(
'<%s class="remarkup-list"%s>',
$tag,
$start_attr);
}
$out[] = "\n";
}
$number = $starts_at;
foreach ($tree as $item) {
if ($this->getEngine()->isTextMode()) {
if ($item['text'] === null) {
// Don't render anything.
} else {
$indent = str_repeat(' ', 2 * $level);
$out[] = $indent;
if ($item['mark'] !== null) {
if ($item['mark']) {
$out[] = '[X] ';
} else {
$out[] = '[ ] ';
}
} else {
switch ($style) {
case '#':
$out[] = $number.'. ';
$number++;
break;
case '-':
$out[] = '- ';
break;
}
}
$parts = preg_split('/\n{2,}/', $item['text']);
foreach ($parts as $key => $part) {
if ($key != 0) {
$out[] = "\n\n ".$indent;
}
$out[] = $this->applyRules($part);
}
$out[] = "\n";
}
} else {
if ($item['text'] === null) {
$out[] = hsprintf('<li class="remarkup-list-item phantom-item">');
} else {
if ($item['mark'] !== null) {
if ($item['mark'] == true) {
$out[] = hsprintf(
'<li class="remarkup-list-item remarkup-checked-item">');
} else {
$out[] = hsprintf(
'<li class="remarkup-list-item remarkup-unchecked-item">');
}
$out[] = phutil_tag(
'input',
array(
'type' => 'checkbox',
'checked' => ($item['mark'] ? 'checked' : null),
'disabled' => 'disabled',
));
$out[] = ' ';
} else {
$out[] = hsprintf('<li class="remarkup-list-item">');
}
$parts = preg_split('/\n{2,}/', $item['text']);
foreach ($parts as $key => $part) {
if ($key != 0) {
$out[] = array(
"\n",
phutil_tag('br'),
phutil_tag('br'),
"\n",
);
}
$out[] = $this->applyRules($part);
}
}
}
if ($item['items']) {
$subitems = $this->renderTree($item['items'], $level + 1, $has_marks);
foreach ($subitems as $i) {
$out[] = $i;
}
}
if (!$this->getEngine()->isTextMode()) {
$out[] = hsprintf("</li>\n");
}
}
if (!$this->getEngine()->isTextMode()) {
switch ($style) {
case '#':
$out[] = hsprintf('</ol>');
break;
case '-':
$out[] = hsprintf('</ul>');
break;
}
}
return $out;
}
}

View file

@ -0,0 +1,93 @@
<?php
final class PhutilRemarkupLiteralBlockRule extends PhutilRemarkupBlockRule {
public function getPriority() {
return 450;
}
public function getMatchingLineCount(array $lines, $cursor) {
// NOTE: We're consuming all continguous blocks of %%% literals, so this:
//
// %%%a%%%
// %%%b%%%
//
// ...is equivalent to:
//
// %%%a
// b%%%
//
// If they are separated by a blank newline, they are parsed as two
// different blocks. This more clearly represents the original text in the
// output text and assists automated escaping of blocks coming into the
// system.
$num_lines = 0;
while (preg_match('/^\s*%%%/', $lines[$cursor])) {
$num_lines++;
// If the line has ONLY "%%%", the block opener doesn't get to double
// up as a block terminator.
if (preg_match('/^\s*%%%\s*\z/', $lines[$cursor])) {
$num_lines++;
$cursor++;
}
while (isset($lines[$cursor])) {
if (!preg_match('/%%%\s*$/', $lines[$cursor])) {
$num_lines++;
$cursor++;
continue;
}
break;
}
$cursor++;
$found_empty = false;
while (isset($lines[$cursor])) {
if (!strlen(trim($lines[$cursor]))) {
$num_lines++;
$cursor++;
$found_empty = true;
continue;
}
break;
}
if ($found_empty) {
// If there's an empty line after the block, stop merging blocks.
break;
}
if (!isset($lines[$cursor])) {
// If we're at the end of the input, stop looking for more lines.
break;
}
}
return $num_lines;
}
public function markupText($text, $children) {
$text = rtrim($text);
$text = phutil_split_lines($text, $retain_endings = true);
foreach ($text as $key => $line) {
$line = preg_replace('/^\s*%%%/', '', $line);
$line = preg_replace('/%%%(\s*)\z/', '\1', $line);
$text[$key] = $line;
}
if ($this->getEngine()->isTextMode()) {
return implode('', $text);
}
return phutil_tag(
'p',
array(
'class' => 'remarkup-literal',
),
phutil_implode_html(phutil_tag('br', array()), $text));
}
}

View file

@ -0,0 +1,121 @@
<?php
final class PhutilRemarkupNoteBlockRule extends PhutilRemarkupBlockRule {
public function getMatchingLineCount(array $lines, $cursor) {
$num_lines = 0;
if (preg_match($this->getRegEx(), $lines[$cursor])) {
$num_lines++;
$cursor++;
while (isset($lines[$cursor])) {
if (trim($lines[$cursor])) {
$num_lines++;
$cursor++;
continue;
}
break;
}
}
return $num_lines;
}
public function markupText($text, $children) {
$matches = array();
preg_match($this->getRegEx(), $text, $matches);
if (idx($matches, 'showword')) {
$word = $matches['showword'];
$show = true;
} else {
$word = $matches['hideword'];
$show = false;
}
$class_suffix = phutil_utf8_strtolower($word);
// This is the "(IMPORTANT)" or "NOTE:" part.
$word_part = rtrim(substr($text, 0, strlen($matches[0])));
// This is the actual text.
$text_part = substr($text, strlen($matches[0]));
$text_part = $this->applyRules(rtrim($text_part));
$text_mode = $this->getEngine()->isTextMode();
$html_mail_mode = $this->getEngine()->isHTMLMailMode();
if ($text_mode) {
return $word_part.' '.$text_part;
}
if ($show) {
$content = array(
phutil_tag(
'span',
array(
'class' => 'remarkup-note-word',
),
$word_part),
' ',
$text_part,
);
} else {
$content = $text_part;
}
if ($html_mail_mode) {
if ($class_suffix == 'important') {
$attributes = array(
'style' => 'margin: 16px 0;
padding: 12px;
border-left: 3px solid #c0392b;
background: #f4dddb;',
);
} else if ($class_suffix == 'note') {
$attributes = array(
'style' => 'margin: 16px 0;
padding: 12px;
border-left: 3px solid #2980b9;
background: #daeaf3;',
);
} else if ($class_suffix == 'warning') {
$attributes = array(
'style' => 'margin: 16px 0;
padding: 12px;
border-left: 3px solid #f1c40f;
background: #fdf5d4;',
);
}
} else {
$attributes = array(
'class' => 'remarkup-'.$class_suffix,
);
}
return phutil_tag(
'div',
$attributes,
$content);
}
private function getRegEx() {
$words = array(
'NOTE',
'IMPORTANT',
'WARNING',
);
foreach ($words as $k => $word) {
$words[$k] = preg_quote($word, '/');
}
$words = implode('|', $words);
return
'/^(?:'.
'(?:\((?P<hideword>'.$words.')\))'.
'|'.
'(?:(?P<showword>'.$words.'):))\s*'.
'/';
}
}

View file

@ -0,0 +1,108 @@
<?php
abstract class PhutilRemarkupQuotedBlockRule
extends PhutilRemarkupBlockRule {
final public function supportsChildBlocks() {
return true;
}
final protected function normalizeQuotedBody($text) {
$text = phutil_split_lines($text, true);
foreach ($text as $key => $line) {
$text[$key] = substr($line, 1);
}
// If every line in the block is empty or begins with at least one leading
// space, strip the initial space off each line. When we quote text, we
// normally add "> " (with a space) to the beginning of each line, which
// can disrupt some other rules. If the block appears to have this space
// in front of each line, remove it.
$strip_space = true;
foreach ($text as $key => $line) {
$len = strlen($line);
if (!$len) {
// We'll still strip spaces if there are some completely empty
// lines, they may have just had trailing whitespace trimmed.
continue;
}
// If this line is part of a nested quote block, just ignore it when
// realigning this quote block. It's either an author attribution
// line with ">>!", or we'll deal with it in a subrule when processing
// the nested quote block.
if ($line[0] == '>') {
continue;
}
if ($line[0] == ' ' || $line[0] == "\n") {
continue;
}
// The first character of this line is something other than a space, so
// we can't strip spaces.
$strip_space = false;
break;
}
if ($strip_space) {
foreach ($text as $key => $line) {
$len = strlen($line);
if (!$len) {
continue;
}
if ($line[0] !== ' ') {
continue;
}
$text[$key] = substr($line, 1);
}
}
// Strip leading empty lines.
foreach ($text as $key => $line) {
if (!strlen(trim($line))) {
unset($text[$key]);
}
}
return implode('', $text);
}
final protected function getQuotedText($text) {
$text = rtrim($text, "\n");
$no_whitespace = array(
// For readability, we render nested quotes as ">> quack",
// not "> > quack".
'>' => true,
// If the line is empty except for a newline, do not add an
// unnecessary dangling space.
"\n" => true,
);
$text = phutil_split_lines($text, true);
foreach ($text as $key => $line) {
$c = null;
if (isset($line[0])) {
$c = $line[0];
} else {
$c = null;
}
if (isset($no_whitespace[$c])) {
$text[$key] = '>'.$line;
} else {
$text[$key] = '> '.$line;
}
}
$text = implode('', $text);
return $text;
}
}

View file

@ -0,0 +1,47 @@
<?php
final class PhutilRemarkupQuotesBlockRule
extends PhutilRemarkupQuotedBlockRule {
public function getMatchingLineCount(array $lines, $cursor) {
$pos = $cursor;
if (preg_match('/^>/', $lines[$pos])) {
do {
++$pos;
} while (isset($lines[$pos]) && preg_match('/^>/', $lines[$pos]));
}
return ($pos - $cursor);
}
public function extractChildText($text) {
return array('', $this->normalizeQuotedBody($text));
}
public function markupText($text, $children) {
if ($this->getEngine()->isTextMode()) {
return $this->getQuotedText($children);
}
$attributes = array();
if ($this->getEngine()->isHTMLMailMode()) {
$style = array(
'border-left: 3px solid #a7b5bf;',
'color: #464c5c;',
'font-style: italic;',
'margin: 4px 0 12px 0;',
'padding: 4px 12px;',
'background-color: #f8f9fc;',
);
$attributes['style'] = implode(' ', $style);
}
return phutil_tag(
'blockquote',
$attributes,
$children);
}
}

Some files were not shown because too many files have changed in this diff Show more