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:
parent
b2b17485b9
commit
9316cbf7fd
232 changed files with 17837 additions and 1 deletions
3
.arclint
3
.arclint
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"exclude": [
|
||||
"(^externals/)",
|
||||
"(^webroot/rsrc/externals/(?!javelin/))"
|
||||
"(^webroot/rsrc/externals/(?!javelin/))",
|
||||
"(/__tests__/data/)"
|
||||
],
|
||||
"linters": {
|
||||
"chmod": {
|
||||
|
|
|
@ -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',
|
||||
|
|
80
src/applications/auth/adapter/PhutilAmazonAuthAdapter.php
Normal file
80
src/applications/auth/adapter/PhutilAmazonAuthAdapter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
86
src/applications/auth/adapter/PhutilAsanaAuthAdapter.php
Normal file
86
src/applications/auth/adapter/PhutilAsanaAuthAdapter.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
123
src/applications/auth/adapter/PhutilAuthAdapter.php
Normal file
123
src/applications/auth/adapter/PhutilAuthAdapter.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
73
src/applications/auth/adapter/PhutilBitbucketAuthAdapter.php
Normal file
73
src/applications/auth/adapter/PhutilBitbucketAuthAdapter.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
84
src/applications/auth/adapter/PhutilDisqusAuthAdapter.php
Normal file
84
src/applications/auth/adapter/PhutilDisqusAuthAdapter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
42
src/applications/auth/adapter/PhutilEmptyAuthAdapter.php
Normal file
42
src/applications/auth/adapter/PhutilEmptyAuthAdapter.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
114
src/applications/auth/adapter/PhutilFacebookAuthAdapter.php
Normal file
114
src/applications/auth/adapter/PhutilFacebookAuthAdapter.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
72
src/applications/auth/adapter/PhutilGitHubAuthAdapter.php
Normal file
72
src/applications/auth/adapter/PhutilGitHubAuthAdapter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
105
src/applications/auth/adapter/PhutilGoogleAuthAdapter.php
Normal file
105
src/applications/auth/adapter/PhutilGoogleAuthAdapter.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
164
src/applications/auth/adapter/PhutilJIRAAuthAdapter.php
Normal file
164
src/applications/auth/adapter/PhutilJIRAAuthAdapter.php
Normal 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');
|
||||
}
|
||||
|
||||
}
|
505
src/applications/auth/adapter/PhutilLDAPAuthAdapter.php
Normal file
505
src/applications/auth/adapter/PhutilLDAPAuthAdapter.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
211
src/applications/auth/adapter/PhutilOAuth1AuthAdapter.php
Normal file
211
src/applications/auth/adapter/PhutilOAuth1AuthAdapter.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
228
src/applications/auth/adapter/PhutilOAuthAuthAdapter.php
Normal file
228
src/applications/auth/adapter/PhutilOAuthAuthAdapter.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
102
src/applications/auth/adapter/PhutilPhabricatorAuthAdapter.php
Normal file
102
src/applications/auth/adapter/PhutilPhabricatorAuthAdapter.php
Normal 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, '/');
|
||||
}
|
||||
|
||||
}
|
61
src/applications/auth/adapter/PhutilSlackAuthAdapter.php
Normal file
61
src/applications/auth/adapter/PhutilSlackAuthAdapter.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
76
src/applications/auth/adapter/PhutilTwitchAuthAdapter.php
Normal file
76
src/applications/auth/adapter/PhutilTwitchAuthAdapter.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
75
src/applications/auth/adapter/PhutilTwitterAuthAdapter.php
Normal file
75
src/applications/auth/adapter/PhutilTwitterAuthAdapter.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
73
src/applications/auth/adapter/PhutilWordPressAuthAdapter.php
Normal file
73
src/applications/auth/adapter/PhutilWordPressAuthAdapter.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Authentication is not configured correctly.
|
||||
*/
|
||||
final class PhutilAuthConfigurationException extends PhutilAuthException {}
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* The user provided invalid credentials.
|
||||
*/
|
||||
final class PhutilAuthCredentialException extends PhutilAuthException {}
|
7
src/applications/auth/exception/PhutilAuthException.php
Normal file
7
src/applications/auth/exception/PhutilAuthException.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Abstract exception class for errors encountered during authentication
|
||||
* workflows.
|
||||
*/
|
||||
abstract class PhutilAuthException extends Exception {}
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
final class PhutilCalendarDocumentNode
|
||||
extends PhutilCalendarContainerNode {
|
||||
|
||||
const NODETYPE = 'document';
|
||||
|
||||
public function getEvents() {
|
||||
return $this->getChildrenOfType(PhutilCalendarEventNode::NODETYPE);
|
||||
}
|
||||
|
||||
}
|
181
src/applications/calendar/parser/data/PhutilCalendarDuration.php
Normal file
181
src/applications/calendar/parser/data/PhutilCalendarDuration.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
20
src/applications/calendar/parser/data/PhutilCalendarNode.php
Normal file
20
src/applications/calendar/parser/data/PhutilCalendarNode.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
final class PhutilCalendarRawNode
|
||||
extends PhutilCalendarContainerNode {
|
||||
|
||||
const NODETYPE = 'raw';
|
||||
|
||||
}
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
final class PhutilCalendarRootNode
|
||||
extends PhutilCalendarContainerNode {
|
||||
|
||||
const NODETYPE = 'root';
|
||||
|
||||
public function getDocuments() {
|
||||
return $this->getChildrenOfType(PhutilCalendarDocumentNode::NODETYPE);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -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.'));
|
||||
}
|
||||
|
||||
}
|
919
src/applications/calendar/parser/ics/PhutilICSParser.php
Normal file
919
src/applications/calendar/parser/ics/PhutilICSParser.php
Normal 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';
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
387
src/applications/calendar/parser/ics/PhutilICSWriter.php
Normal file
387
src/applications/calendar/parser/ics/PhutilICSWriter.php
Normal 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,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DATA;VALUE=BINARY;ENCODING=BASE64:<QUACK! QUACK!>
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DUCK;VALUE=BOOLEAN:QUACK
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:quack
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DURATION:quack
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DURATION:
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1 @@
|
|||
END:VCALENDAR
|
|
@ -0,0 +1,2 @@
|
|||
BEGIN:VCALENDAR
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
A;B="C:D
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
A;B:C
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
PEANUTBUTTER&JELLY:sandwich
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20130101,20130101
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DURATION:P1W,P2W
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,2 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
TRIANGLE;color=red
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,4 @@
|
|||
BEGIN:A
|
||||
BEGIN:B
|
||||
END:A
|
||||
END:B
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=A,B:20160915T090000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1 @@
|
|||
NAME:value
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
STORY:The duck coughed up an unescaped backslash: \
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
SQUARE;color=red"
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DUCK;VALUE=BOOLEAN:TRUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
|
@ -0,0 +1,4 @@
|
|||
BEGIN:VCALENDAR
|
||||
END:VCALENDAR
|
||||
BEGIN:VCALENDAR
|
||||
END:VCALENDAR
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
107
src/infrastructure/lipsum/PhutilLipsumContextFreeGrammar.php
Normal file
107
src/infrastructure/lipsum/PhutilLipsumContextFreeGrammar.php
Normal 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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
155
src/infrastructure/lipsum/PhutilRealNameContextFreeGrammar.php
Normal file
155
src/infrastructure/lipsum/PhutilRealNameContextFreeGrammar.php
Normal 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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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(''));
|
||||
}
|
||||
|
||||
}
|
|
@ -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]',
|
||||
));
|
||||
}
|
||||
|
||||
}
|
|
@ -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]',
|
||||
));
|
||||
}
|
||||
|
||||
}
|
176
src/infrastructure/markup/PhutilRemarkupBlockStorage.php
Normal file
176
src/infrastructure/markup/PhutilRemarkupBlockStorage.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
170
src/infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php
Normal file
170
src/infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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)));
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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*'.
|
||||
'/';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue