diff --git a/Makefile b/Makefile index 8aeb2ef..c0c914a 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ ifeq (, $(composer)) ./get_composer.sh mv composer.phar $(build_tools_directory)/composer_fresh.phar endif + @test -e $(build_tools_directory)/composer.phar || ln -s composer_fresh.phar $(build_tools_directory)/composer.phar # Installs composer LTS version from web if not already installed. # TODO Switch from pinning specific version to LTS pinning see @@ -49,9 +50,10 @@ update: composer .PHONY: php70_mode php70_mode: composer_lts git checkout composer.json composer.lock + rm -f composer.lock rm $(build_tools_directory)/composer.phar || true ln $(build_tools_directory)/composer_lts.phar $(build_tools_directory)/composer.phar - php $(build_tools_directory)/composer.phar require sabre/vobject:'<4.3' sabre/uri:'<2.2' sabre/xml:'<2.2' psr/log:'<2' phpunit/phpunit:'<10' phar-io/manifest:'<2' phpunit/php-code-coverage:'<6' phpunit/php-file-iterator:'<2' phpunit/php-timer:'<6' phpunit/php-text-template:'<2' phar-io/version:'<3' + php $(build_tools_directory)/composer.phar require sabre/vobject:'<4.3' sabre/uri:'<2.2' sabre/xml:'<2.2' psr/log:'<2' phpunit/phpunit:'<10' phar-io/manifest:'<2' phpunit/php-code-coverage:'<6' phpunit/php-file-iterator:'<2' phpunit/php-timer:'<6' phpunit/php-text-template:'<2' phar-io/version:'<3' squizlabs/php_codesniffer:'<3.6' # Lint for PHP 7.0 . This will fail in case podman is not available podman run --rm --name php70 -v "$(PWD)":"$(PWD)" -w "$(PWD)" docker.io/jetpulp/php70-cli sh -c "! (find . -type f -name \"*.php\" -not -path \"./tests/*\" $1 -exec php -l -n {} \; | grep -v \"No syntax errors detected\")" || true @@ -69,16 +71,12 @@ php81_mode: composer # Linting with PHP-CS .PHONY: lint -lint: - # Make sure devtools are available +lint: composer php $(build_tools_directory)/composer.phar install --prefer-dist - - # Lint with CodeSniffer vendor/bin/phpcs src/ -# Run Unit tests .PHONY: unit_test -unit_test: +unit_test: composer php $(build_tools_directory)/composer.phar install --prefer-dist vendor/bin/phpunit -c tests/phpunit.xml --testdox diff --git a/composer.json b/composer.json index df6d655..cff16dd 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,14 @@ "require": { "php": ">=5.6", "sabre/vobject": ">=4.2", - "audriga/jmap-openxport": "~1" + "audriga/jmap-openxport": "dev-7676-ietf_implementation" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/audriga/openxport-jmap" + } + ], "require-dev": { "phpunit/phpunit": ">=9", "squizlabs/php_codesniffer": ">=3" @@ -45,4 +51,4 @@ } } -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index a3d2b6a..032198a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4f3e1c83c181f758cc87dc123e97784c", + "content-hash": "fb84ce63ffcf5963aa1bd15982d6403b", "packages": [ { "name": "audriga/jmap-openxport", - "version": "1.7.2", + "version": "dev-7676-ietf_implementation", "source": { "type": "git", "url": "https://github.com/audriga/openxport-jmap.git", - "reference": "e070b3a592b6c2c1822d3673788a5af2cb7f6c58" + "reference": "91e245deb9f47424f0d771126c347a8126049361" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/audriga/openxport-jmap/zipball/e070b3a592b6c2c1822d3673788a5af2cb7f6c58", - "reference": "e070b3a592b6c2c1822d3673788a5af2cb7f6c58", + "url": "https://api.github.com/repos/audriga/openxport-jmap/zipball/91e245deb9f47424f0d771126c347a8126049361", + "reference": "91e245deb9f47424f0d771126c347a8126049361", "shasum": "" }, "require": { @@ -39,7 +39,6 @@ "src/" ] }, - "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -62,10 +61,10 @@ "migration" ], "support": { - "issues": "https://github.com/audriga/openxport-jmap/issues", - "source": "https://github.com/audriga/openxport-jmap/tree/1.7.2" + "source": "https://github.com/audriga/openxport-jmap/tree/7676-ietf_implementation", + "issues": "https://github.com/audriga/openxport-jmap/issues" }, - "time": "2024-01-11T12:47:38+00:00" + "time": "2026-04-15T14:05:21+00:00" }, { "name": "psr/log", @@ -119,28 +118,29 @@ }, { "name": "sabre/uri", - "version": "3.0.1", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/sabre-io/uri.git", - "reference": "1774043c843f1db7654ecc93368a98be29b07544" + "reference": "4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/uri/zipball/1774043c843f1db7654ecc93368a98be29b07544", - "reference": "1774043c843f1db7654ecc93368a98be29b07544", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc", + "reference": "4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.17", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-strict-rules": "^1.5", - "phpunit/phpunit": "^9.6" + "friendsofphp/php-cs-fixer": "^3.94", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "rector/rector": "^2.3" }, "type": "library", "autoload": { @@ -175,20 +175,20 @@ "issues": "https://github.com/sabre-io/uri/issues", "source": "https://github.com/fruux/sabre-uri" }, - "time": "2023-06-09T07:04:02+00:00" + "time": "2026-04-01T08:19:11+00:00" }, { "name": "sabre/vobject", - "version": "4.5.4", + "version": "4.5.8", "source": { "type": "git", "url": "https://github.com/sabre-io/vobject.git", - "reference": "a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772" + "reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/vobject/zipball/a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772", - "reference": "a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1", + "reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1", "shasum": "" }, "require": { @@ -198,9 +198,9 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "~2.17.1", - "phpstan/phpstan": "^0.12", + "phpstan/phpstan": "^0.12 || ^1.12 || ^2.0", "phpunit/php-invoker": "^2.0 || ^3.1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.0" + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" }, "suggest": { "hoa/bench": "If you would like to run the benchmark scripts" @@ -279,20 +279,20 @@ "issues": "https://github.com/sabre-io/vobject/issues", "source": "https://github.com/fruux/sabre-vobject" }, - "time": "2023-11-09T12:54:37+00:00" + "time": "2026-01-12T10:45:19+00:00" }, { "name": "sabre/xml", - "version": "4.0.4", + "version": "4.0.7", "source": { "type": "git", "url": "https://github.com/sabre-io/xml.git", - "reference": "99caa5dd248776ca6a1e1d2cfdef556a3fa63456" + "reference": "53db7bad0953949fb61037fbf9b13b421492395c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/xml/zipball/99caa5dd248776ca6a1e1d2cfdef556a3fa63456", - "reference": "99caa5dd248776ca6a1e1d2cfdef556a3fa63456", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/53db7bad0953949fb61037fbf9b13b421492395c", + "reference": "53db7bad0953949fb61037fbf9b13b421492395c", "shasum": "" }, "require": { @@ -304,9 +304,10 @@ "sabre/uri": ">=2.0,<4.0.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.38", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.6" + "friendsofphp/php-cs-fixer": "^3.94", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6", + "rector/rector": "^2.3" }, "type": "library", "autoload": { @@ -348,22 +349,22 @@ "issues": "https://github.com/sabre-io/xml/issues", "source": "https://github.com/fruux/sabre-xml" }, - "time": "2023-11-09T10:47:15+00:00" + "time": "2026-04-02T11:40:41+00:00" } ], "packages-dev": [ { "name": "myclabs/deep-copy", - "version": "1.11.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -371,11 +372,12 @@ }, "conflict": { "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", @@ -401,7 +403,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -409,20 +411,20 @@ "type": "tidelift" } ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.0.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4a21235f7e56e713259a6f76bf4b5ea08502b9dc", - "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -433,7 +435,7 @@ }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" @@ -441,7 +443,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -465,26 +467,27 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-01-07T17:17:35+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { "ext-dom": "*", + "ext-libxml": "*", "ext-phar": "*", "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", @@ -525,9 +528,15 @@ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.3" + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-07-20T11:28:43+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { "name": "phar-io/version", @@ -582,35 +591,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "11.0.0", + "version": "10.1.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "5e238e4b982cb272bf9faeee6f33af83d465d0e2" + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/5e238e4b982cb272bf9faeee6f33af83d465d0e2", - "reference": "5e238e4b982cb272bf9faeee6f33af83d465d0e2", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.0", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.0", - "phpunit/php-text-template": "^4.0", - "sebastian/code-unit-reverse-lookup": "^4.0", - "sebastian/complexity": "^4.0", - "sebastian/environment": "^7.0", - "sebastian/lines-of-code": "^3.0", - "sebastian/version": "^5.0", - "theseer/tokenizer": "^1.2.0" + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -619,7 +628,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0-dev" + "dev-main": "10.1.x-dev" } }, "autoload": { @@ -648,7 +657,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" }, "funding": [ { @@ -656,32 +665,32 @@ "type": "github" } ], - "time": "2024-02-02T06:03:46+00:00" + "time": "2024-08-22T04:31:57+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.0.0", + "version": "4.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "99e95c94ad9500daca992354fa09d7b99abe2210" + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/99e95c94ad9500daca992354fa09d7b99abe2210", - "reference": "99e95c94ad9500daca992354fa09d7b99abe2210", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -709,7 +718,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" }, "funding": [ { @@ -717,28 +726,28 @@ "type": "github" } ], - "time": "2024-02-02T06:05:04+00:00" + "time": "2023-08-31T06:24:48+00:00" }, { "name": "phpunit/php-invoker", - "version": "5.0.0", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5d8d9355a16d8cc5a1305b0a85342cfa420612be" + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5d8d9355a16d8cc5a1305b0a85342cfa420612be", - "reference": "5d8d9355a16d8cc5a1305b0a85342cfa420612be", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "suggest": { "ext-pcntl": "*" @@ -746,7 +755,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -772,8 +781,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" }, "funding": [ { @@ -781,32 +789,32 @@ "type": "github" } ], - "time": "2024-02-02T06:05:50+00:00" + "time": "2023-02-03T06:56:09+00:00" }, { "name": "phpunit/php-text-template", - "version": "4.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "d38f6cbff1cdb6f40b03c9811421561668cc133e" + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/d38f6cbff1cdb6f40b03c9811421561668cc133e", - "reference": "d38f6cbff1cdb6f40b03c9811421561668cc133e", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -833,7 +841,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" }, "funding": [ { @@ -841,32 +849,32 @@ "type": "github" } ], - "time": "2024-02-02T06:06:56+00:00" + "time": "2023-08-31T14:07:24+00:00" }, { "name": "phpunit/php-timer", - "version": "7.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "8a59d9e25720482ee7fcdf296595e08795b84dc5" + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8a59d9e25720482ee7fcdf296595e08795b84dc5", - "reference": "8a59d9e25720482ee7fcdf296595e08795b84dc5", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -892,8 +900,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" }, "funding": [ { @@ -901,20 +908,20 @@ "type": "github" } ], - "time": "2024-02-02T06:08:01+00:00" + "time": "2023-02-03T06:57:52+00:00" }, { "name": "phpunit/phpunit", - "version": "11.0.2", + "version": "10.5.63", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "2f281e7e6776aea920cab5fc5a48d0fefbe1f39e" + "reference": "33198268dad71e926626b618f3ec3966661e4d90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2f281e7e6776aea920cab5fc5a48d0fefbe1f39e", - "reference": "2f281e7e6776aea920cab5fc5a48d0fefbe1f39e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", "shasum": "" }, "require": { @@ -924,25 +931,26 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.10.1", - "phar-io/manifest": "^2.0.3", - "phar-io/version": "^3.0.2", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0", - "phpunit/php-file-iterator": "^5.0", - "phpunit/php-invoker": "^5.0", - "phpunit/php-text-template": "^4.0", - "phpunit/php-timer": "^7.0", - "sebastian/cli-parser": "^3.0", - "sebastian/code-unit": "^3.0", - "sebastian/comparator": "^6.0", - "sebastian/diff": "^6.0", - "sebastian/environment": "^7.0", - "sebastian/exporter": "^6.0", - "sebastian/global-state": "^7.0", - "sebastian/object-enumerator": "^6.0", - "sebastian/type": "^5.0", - "sebastian/version": "^5.0" + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" }, "suggest": { "ext-soap": "To be able to generate mocks based on WSDL files" @@ -953,7 +961,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0-dev" + "dev-main": "10.5-dev" } }, "autoload": { @@ -985,7 +993,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.2" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" }, "funding": [ { @@ -996,37 +1004,45 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-02-04T09:09:14+00:00" + "time": "2026-01-27T05:48:37+00:00" }, { "name": "sebastian/cli-parser", - "version": "3.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "efd6ce5bb8131fe981e2f879dbd47605fbe0cc6f" + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/efd6ce5bb8131fe981e2f879dbd47605fbe0cc6f", - "reference": "efd6ce5bb8131fe981e2f879dbd47605fbe0cc6f", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "2.0-dev" } }, "autoload": { @@ -1050,7 +1066,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" }, "funding": [ { @@ -1058,32 +1074,32 @@ "type": "github" } ], - "time": "2024-02-02T05:48:04+00:00" + "time": "2024-03-02T07:12:49+00:00" }, { "name": "sebastian/code-unit", - "version": "3.0.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "6634549cb8d702282a04a774e36a7477d2bd9015" + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/6634549cb8d702282a04a774e36a7477d2bd9015", - "reference": "6634549cb8d702282a04a774e36a7477d2bd9015", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "2.0-dev" } }, "autoload": { @@ -1106,8 +1122,7 @@ "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.0" + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" }, "funding": [ { @@ -1115,32 +1130,32 @@ "type": "github" } ], - "time": "2024-02-02T05:50:41+00:00" + "time": "2023-02-03T06:58:43+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "4.0.0", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "df80c875d3e459b45c6039e4d9b71d4fbccae25d" + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/df80c875d3e459b45c6039e4d9b71d4fbccae25d", - "reference": "df80c875d3e459b45c6039e4d9b71d4fbccae25d", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -1162,8 +1177,7 @@ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" }, "funding": [ { @@ -1171,36 +1185,36 @@ "type": "github" } ], - "time": "2024-02-02T05:52:17+00:00" + "time": "2023-02-03T06:59:15+00:00" }, { "name": "sebastian/comparator", - "version": "6.0.0", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "bd0f2fa5b9257c69903537b266ccb80fcf940db8" + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/bd0f2fa5b9257c69903537b266ccb80fcf940db8", - "reference": "bd0f2fa5b9257c69903537b266ccb80fcf940db8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1240,41 +1254,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2024-02-02T05:53:45+00:00" + "time": "2026-01-24T09:25:16+00:00" }, { "name": "sebastian/complexity", - "version": "4.0.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "88a434ad86150e11a606ac4866b09130712671f0" + "reference": "68ff824baeae169ec9f2137158ee529584553799" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/88a434ad86150e11a606ac4866b09130712671f0", - "reference": "88a434ad86150e11a606ac4866b09130712671f0", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "3.2-dev" } }, "autoload": { @@ -1298,7 +1324,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" }, "funding": [ { @@ -1306,33 +1332,33 @@ "type": "github" } ], - "time": "2024-02-02T05:55:19+00:00" + "time": "2023-12-21T08:37:17+00:00" }, { "name": "sebastian/diff", - "version": "6.0.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "3e3f502419518897a923aa1c64d51f9def2e0aff" + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3e3f502419518897a923aa1c64d51f9def2e0aff", - "reference": "3e3f502419518897a923aa1c64d51f9def2e0aff", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -1365,7 +1391,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" }, "funding": [ { @@ -1373,27 +1399,27 @@ "type": "github" } ], - "time": "2024-02-02T05:56:35+00:00" + "time": "2024-03-02T07:15:17+00:00" }, { "name": "sebastian/environment", - "version": "7.0.0", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "100d8b855d7180f79f9a9a5c483f2d960581c3ea" + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/100d8b855d7180f79f9a9a5c483f2d960581c3ea", - "reference": "100d8b855d7180f79f9a9a5c483f2d960581c3ea", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "suggest": { "ext-posix": "*" @@ -1401,7 +1427,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -1429,7 +1455,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" }, "funding": [ { @@ -1437,34 +1463,34 @@ "type": "github" } ], - "time": "2024-02-02T05:57:54+00:00" + "time": "2024-03-23T08:47:14+00:00" }, { "name": "sebastian/exporter", - "version": "6.0.0", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "d0c0a93fc746b0c066037f1e7d09104129e868ff" + "reference": "0735b90f4da94969541dac1da743446e276defa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d0c0a93fc746b0c066037f1e7d09104129e868ff", - "reference": "d0c0a93fc746b0c066037f1e7d09104129e868ff", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -1507,43 +1533,55 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-02-02T05:58:52+00:00" + "time": "2025-09-24T06:09:11+00:00" }, { "name": "sebastian/global-state", - "version": "7.0.0", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "590e7cbc6565fa2e26c3df4e629a34bb0bc00c17" + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/590e7cbc6565fa2e26c3df4e629a34bb0bc00c17", - "reference": "590e7cbc6565fa2e26c3df4e629a34bb0bc00c17", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1569,7 +1607,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" }, "funding": [ { @@ -1577,33 +1615,33 @@ "type": "github" } ], - "time": "2024-02-02T05:59:33+00:00" + "time": "2024-03-02T07:19:19+00:00" }, { "name": "sebastian/lines-of-code", - "version": "3.0.0", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "376c5b3f6b43c78fdc049740bca76a7c846706c0" + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/376c5b3f6b43c78fdc049740bca76a7c846706c0", - "reference": "376c5b3f6b43c78fdc049740bca76a7c846706c0", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "2.0-dev" } }, "autoload": { @@ -1627,7 +1665,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" }, "funding": [ { @@ -1635,34 +1673,34 @@ "type": "github" } ], - "time": "2024-02-02T06:00:36+00:00" + "time": "2023-12-21T08:38:20+00:00" }, { "name": "sebastian/object-enumerator", - "version": "6.0.0", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f75f6c460da0bbd9668f43a3dde0ec0ba7faa678" + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f75f6c460da0bbd9668f43a3dde0ec0ba7faa678", - "reference": "f75f6c460da0bbd9668f43a3dde0ec0ba7faa678", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1684,8 +1722,7 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" }, "funding": [ { @@ -1693,32 +1730,32 @@ "type": "github" } ], - "time": "2024-02-02T06:01:29+00:00" + "time": "2023-02-03T07:08:32+00:00" }, { "name": "sebastian/object-reflector", - "version": "4.0.0", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "bb2a6255d30853425fd38f032eb64ced9f7f132d" + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/bb2a6255d30853425fd38f032eb64ced9f7f132d", - "reference": "bb2a6255d30853425fd38f032eb64ced9f7f132d", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -1740,8 +1777,7 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" }, "funding": [ { @@ -1749,32 +1785,32 @@ "type": "github" } ], - "time": "2024-02-02T06:02:18+00:00" + "time": "2023-02-03T07:06:18+00:00" }, { "name": "sebastian/recursion-context", - "version": "6.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "b75224967b5a466925c6d54e68edd0edf8dd4ed4" + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/b75224967b5a466925c6d54e68edd0edf8dd4ed4", - "reference": "b75224967b5a466925c6d54e68edd0edf8dd4ed4", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1805,40 +1841,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2024-02-02T06:08:48+00:00" + "time": "2025-08-10T07:50:56+00:00" }, { "name": "sebastian/type", - "version": "5.0.0", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "b8502785eb3523ca0dd4afe9ca62235590020f3f" + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b8502785eb3523ca0dd4afe9ca62235590020f3f", - "reference": "b8502785eb3523ca0dd4afe9ca62235590020f3f", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -1861,8 +1909,7 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" }, "funding": [ { @@ -1870,29 +1917,29 @@ "type": "github" } ], - "time": "2024-02-02T06:09:34+00:00" + "time": "2023-02-03T07:10:45+00:00" }, { "name": "sebastian/version", - "version": "5.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "13999475d2cb1ab33cb73403ba356a814fdbb001" + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/13999475d2cb1ab33cb73403ba356a814fdbb001", - "reference": "13999475d2cb1ab33cb73403ba356a814fdbb001", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -1915,8 +1962,7 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" }, "funding": [ { @@ -1924,41 +1970,36 @@ "type": "github" } ], - "time": "2024-02-02T06:10:47+00:00" + "time": "2023-02-07T11:34:05+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.8.1", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "14f5fff1e64118595db5408e946f3a22c75807f7" + "reference": "0525c73950de35ded110cffafb9892946d7771b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/14f5fff1e64118595db5408e946f3a22c75807f7", - "reference": "14f5fff1e64118595db5408e946f3a22c75807f7", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", "shasum": "" }, "require": { "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": ">=5.4.0" + "php": ">=7.2.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, "bin": [ "bin/phpcbf", "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -1977,7 +2018,7 @@ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", @@ -2002,22 +2043,26 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-01-11T20:47:48+00:00" + "time": "2025-11-10T16:43:36+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.2", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -2046,7 +2091,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.2" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -2054,17 +2099,19 @@ "type": "github" } ], - "time": "2023-11-20T00:12:19+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "audriga/jmap-openxport": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=5.6" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.2.0" } diff --git a/src/adapter/JSCalendarICalendarAdapter.php b/src/adapter/JSCalendarICalendarAdapter.php index bc6dbb2..3cdf393 100644 --- a/src/adapter/JSCalendarICalendarAdapter.php +++ b/src/adapter/JSCalendarICalendarAdapter.php @@ -14,6 +14,7 @@ use OpenXPort\Jmap\Calendar\Link; use OpenXPort\Jmap\Calendar\RecurrenceRule; use OpenXPort\Jmap\Calendar\Participant; +use OpenXPort\Jmap\Calendar\VirtualLocation; use OpenXPort\Mapper\JSCalendarICalendarMapper; use OpenXPort\Util\AdapterUtil; use OpenXPort\Util\JSCalendarICalendarAdapterUtil; @@ -161,10 +162,13 @@ public function getDescription() return null; } - return $description->getValue(); + $value = $description->getValue(); - // TODO: implement the unescaping mentioned in the ietf conversion standards. - // https://www.ietf.org/archive/id/draft-ietf-calext-jscalendar-icalendar-07.html#name-description. + // Unescape iCalendar text values according to RFC 5545 Section 3.3.11 + $value = str_replace(['\\n', '\\N'], "\n", $value); + $value = str_replace(['\\\\', '\\;', '\\,'], ['\\', ';', ','], $value); + + return $value; } public function setDescription($description) @@ -281,11 +285,56 @@ public function getShowWithoutTime() return null; } - if (!AdapterUtil::isSetNotNullAndNotEmpty($dtStart["VALUE"])) { - return null; + // DATE values convert to showWithoutTime=true + if (isset($dtStart['VALUE']) && strtoupper((string) $dtStart['VALUE']) === 'DATE') { + return true; + } + + // Check for SHOW-WITHOUT-TIME property on timed events + $showWithoutTime = $this->iCalEvent->VEVENT->{'SHOW-WITHOUT-TIME'}; + if (!AdapterUtil::isSetNotNullAndNotEmpty($showWithoutTime)) { + return false; // Default is false for timed events without the property + } + + $value = strtoupper(trim((string) $showWithoutTime)); + + if ($value === 'TRUE') { + return true; + } elseif ($value === 'FALSE') { + return false; } - return $dtStart["VALUE"]->getValue() == "DATE" ? true : null; + $this->logger->warning("Invalid SHOW-WITHOUT-TIME value: " . $value); + return false; + } + + public function setShowWithoutTime($showWithoutTime) + { + if ($showWithoutTime !== true) { + return; + } + + $dtStart = $this->iCalEvent->VEVENT->DTSTART; + if (!AdapterUtil::isSetNotNullAndNotEmpty($dtStart)) { + return; + } + + // If DTSTART already has VALUE=DATE, don't add SHOW-WITHOUT-TIME + if (isset($dtStart['VALUE']) && strtoupper((string) $dtStart['VALUE']) === 'DATE') { + return; + } + + // Remove existing SHOW-WITHOUT-TIME property if present + $existing = $this->iCalEvent->VEVENT->{'SHOW-WITHOUT-TIME'}; + if ($existing) { + $this->iCalEvent->VEVENT->remove($existing); + } + + // Add SHOW-WITHOUT-TIME property + $this->iCalEvent->VEVENT->add('SHOW-WITHOUT-TIME', 'TRUE'); + + // Set the VALUE=BOOLEAN parameter + $this->iCalEvent->VEVENT->{'SHOW-WITHOUT-TIME'}['VALUE'] = 'BOOLEAN'; } public function setDTEnd($start, $duration, $timeZone, $showWithoutTime = null) @@ -347,7 +396,7 @@ public function getDuration() // Create a pattern to return the duration in the correct format. $outputFormat = 'P'; - //TODO: Check whether months/years need to be added. + // Use total days to handle years/months properly if ($interval->format('%d') != 0) { $outputFormat .= '%dD'; } @@ -372,6 +421,30 @@ public function getDuration() return $interval->format($outputFormat); } + /** + * Set the duration of the event from a JSCalendar duration string. + * + * Note: DURATION is mutually exclusive with DTEND in iCalendar. + * If DTEND is already set, this method does nothing. + * + * @param string $duration ISO 8601 duration string (e.g., "PT1H30M", "P1D") + * @return void + */ + public function setDuration($duration) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($duration)) { + return; + } + + // Don't set DURATION if DTEND is already set + if (AdapterUtil::isSetNotNullAndNotEmpty($this->iCalEvent->VEVENT->DTEND)) { + return; + } + + // DURATION is mutually exclusive with DTEND + $this->iCalEvent->VEVENT->add('DURATION', $duration); + } + public function getTimeZone() { $dtStart = $this->iCalEvent->VEVENT->DTSTART; @@ -397,24 +470,38 @@ public function getTimeZone() return $timeZone->getName(); } - //TODO: this might need to be revamped to accomodate for scheduling and non-scheduling properties public function getUpdated() { // Get both the "LAST-MODIFIED" and "DTSTAMP" properties, as only one of // them is converted into the "updated" jmap property. $lastModified = $this->iCalEvent->VEVENT->{'LAST-MODIFIED'}; $dTStamp = $this->iCalEvent->VEVENT->DTSTAMP; + $method = $this->iCalEvent->METHOD; // Check if this is a scheduling message + //or a regular calendar object + $dateUpdated = null; - // As per IETF standard: Use the latest of the ones that is present, - // if they are no scheduling entity. - if (AdapterUtil::isSetNotNullAndNotEmpty($lastModified)) { - $dateUpdated = $lastModified->getDateTime(); - } + // Per RFC 8984: For scheduling messages (METHOD property present), use DTSTAMP. + // For non-scheduling entities, use the latest of LAST-MODIFIED and DTSTAMP. + if (AdapterUtil::isSetNotNullAndNotEmpty($method)) { + // Scheduling entity: use DTSTAMP only + if (AdapterUtil::isSetNotNullAndNotEmpty($dTStamp)) { + $dateUpdated = $dTStamp->getDateTime(); + } + } else { + // Non-scheduling entity: use latest of LAST-MODIFIED or DTSTAMP + // As per IETF standard: Use the latest of the ones that is present, + // if they are no scheduling entity. + if (AdapterUtil::isSetNotNullAndNotEmpty($lastModified)) { + $dateUpdated = $lastModified->getDateTime(); + } - if (AdapterUtil::isSetNotNullAndNotEmpty($dTStamp)) { - $dTStampDateTime = $dTStamp->getDateTime(); - $dateUpdated = $dateUpdated < $dTStampDateTime ? $dTStampDateTime : $dateUpdated; + if (AdapterUtil::isSetNotNullAndNotEmpty($dTStamp)) { + $dTStampDateTime = $dTStamp->getDateTime(); + if (is_null($dateUpdated) || $dateUpdated < $dTStampDateTime) { + $dateUpdated = $dTStampDateTime; + } + } } // This is only the case if neither property is set in the iCal Event. @@ -440,11 +527,23 @@ public function setUpdated($updated) $iCalendarUpdatedDateTime = \DateTime::createFromFormat($iCalFormat, $iCalendarUpdated); + if (!$iCalendarUpdatedDateTime) { + return; + } + + // DTSTAMP if (isset($this->iCalEvent->VEVENT->DTSTAMP)) { $this->iCalEvent->VEVENT->DTSTAMP = $iCalendarUpdatedDateTime; } else { $this->iCalEvent->VEVENT->add('DTSTAMP', $iCalendarUpdatedDateTime); } + + // LAST-MODIFIED + if (isset($this->iCalEvent->VEVENT->{'LAST-MODIFIED'})) { + $this->iCalEvent->VEVENT->{'LAST-MODIFIED'} = $iCalendarUpdatedDateTime; + } else { + $this->iCalEvent->VEVENT->add('LAST-MODIFIED', $iCalendarUpdatedDateTime); + } } public function getUid() @@ -600,10 +699,14 @@ public function getCategories() $jmapKeyWords = []; - $categoryValues = explode(",", $categories); - - foreach ($categoryValues as $cat) { - $jmapKeyWords[$cat] = true; + foreach ($categories as $cat) { + // Parse comma-separated values from each CATEGORIES property + foreach ($cat->getParts() as $value) { + $value = trim($value); + if (AdapterUtil::isSetNotNullAndNotEmpty($value)) { + $jmapKeyWords[$value] = true; + } + } } return $jmapKeyWords; @@ -626,10 +729,8 @@ public function setCategories($keywords) if (count($categories) == 0) { return; } - - $iCalCategories = implode(",", $categories); - - $this->iCalEvent->VEVENT->add("CATEGORIES", $iCalCategories); + // VObject handles comma-separation automatically when given an array + $this->iCalEvent->VEVENT->add("CATEGORIES", $categories); } @@ -675,6 +776,155 @@ public function setLocation($locations) $this->iCalEvent->VEVENT->add("LOCATION", $locationICalEscaped); } + /** + * Get VLOCATION components and convert them to JSCalendar locations. + * + * @return array|null Array of Location objects indexed by numeric keys, or null if none exist + */ + public function getVLocations() + { + $vLocations = $this->iCalEvent->VEVENT->VLOCATION; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($vLocations)) { + return null; + } + + $jmapLocations = []; + $key = 1; + + foreach ($vLocations as $vLocation) { + $jmapLocation = new Location(); + $jmapLocation->setType("Location"); + + // NAME property + $name = $vLocation->NAME; + if (AdapterUtil::isSetNotNullAndNotEmpty($name)) { + $nameValue = addcslashes(stripslashes($name->getValue()), '["\]'); + $jmapLocation->setName($nameValue); + } + + // GEO property: convert to coordinates + $geo = $vLocation->GEO; + if (AdapterUtil::isSetNotNullAndNotEmpty($geo)) { + $geoParts = $geo->getParts(); + if (count($geoParts) >= 2) { + $coordinates = sprintf("geo:%s,%s", $geoParts[0], $geoParts[1]); + $jmapLocation->setCoordinates($coordinates); + } + } + + $jmapLocations[$key] = $jmapLocation; + $key++; + } + + return $jmapLocations; + } + + /** + * Set VLOCATION components from JSCalendar locations. + * + * @param array $locations Array of Location objects to convert + * + * @return void + */ + public function setVLocations($locations) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($locations)) { + return; + } + + foreach ($locations as $location) { + // Only create VLOCATION if we have coordinates + if (!AdapterUtil::isSetNotNullAndNotEmpty($location->getCoordinates())) { + continue; + } + + // Create a new VLOCATION component using the VEvent class + // (Sabre uses VEvent for most component types) + $vLocationComponent = new \Sabre\VObject\Component\VEvent($this->iCalEvent, 'VLOCATION'); + + // UID (required) + $vLocationComponent->add('UID', uniqid("vloc-", true)); + + // NAME (required) + $name = $location->getName(); + if (AdapterUtil::isSetNotNullAndNotEmpty($name)) { + $nameICalEscaped = addcslashes(stripslashes($name), "[,;]"); + $vLocationComponent->add('NAME', $nameICalEscaped); + } else { + $vLocationComponent->add('NAME', 'Location'); + } + + // GEO from coordinates + $coordinates = $location->getCoordinates(); + if (strpos($coordinates, 'geo:') === 0) { + $coords = substr($coordinates, 4); + $parts = explode(',', $coords); + + if (count($parts) >= 2) { + $latitude = trim($parts[0]); + $longitude = trim($parts[1]); + $vLocationComponent->add('GEO', "$latitude;$longitude"); + } + } + + // Add the component to the VEVENT's children array + $this->iCalEvent->VEVENT->add($vLocationComponent); + } + } + + /** + * Get geographic coordinates as JSCalendar geo URI format. + * Converts iCalendar GEO property (latitude;longitude) to geo: URI format. + * + * @return string|null Geo URI (e.g., "geo:37.386,-122.082"), or null if not set + */ + public function getGeo() + { + $geo = $this->iCalEvent->VEVENT->GEO; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($geo)) { + return null; + } + + $geoParts = $geo->getParts(); + if (count($geoParts) >= 2) { + // Return as geo: URI format for JSCalendar + return sprintf("geo:%s,%s", $geoParts[0], $geoParts[1]); + } + + return null; + } + + /** + * Set geographic coordinates from JSCalendar geo URI format. + * Converts geo: URI format to iCalendar GEO property (latitude;longitude). + * + * @param string $coordinates Geo URI in format "geo:latitude,longitude" + * + * @return void + */ + public function setGeo($coordinates) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($coordinates)) { + return; + } + + // Expect geo: URI format: "geo:latitude,longitude" + if (strpos($coordinates, 'geo:') === 0) { + $coords = substr($coordinates, 4); + $parts = explode(',', $coords); + + if (count($parts) >= 2) { + $latitude = trim($parts[0]); + $longitude = trim($parts[1]); + + // GEO format is "latitude;longitude" + $this->iCalEvent->VEVENT->add('GEO', [$latitude, $longitude]); + } + } + } + public function getFreeBusy() { $freeBusy = $this->iCalEvent->VEVENT->TRANSP; @@ -684,7 +934,7 @@ public function getFreeBusy() } // "free" is supposed to be the default value. - return $freeBusy->getValue() == 'OPAGUE' ? 'busy' : 'free'; + return $freeBusy->getValue() == 'OPAQUE' ? 'busy' : 'free'; } public function setFreeBusy($freeBusy) @@ -694,7 +944,7 @@ public function setFreeBusy($freeBusy) } // "OPAGUE" is supposed to be the default value. - $iCalFreeBusy = $freeBusy == 'free' ? 'TRANSPARENT' : 'OPAGUE'; + $iCalFreeBusy = $freeBusy == 'free' ? 'TRANSPARENT' : 'OPAQUE'; $this->iCalEvent->VEVENT->add("TRANSP", $iCalFreeBusy); } @@ -779,12 +1029,20 @@ public function getAlerts() // "DISPLAY" and "AUDIO" are both converted to "display", as there is no direct // counterpart for "AUDIO" in JSCalendar. - if (strcmp($action, "DISPLAY") === 0 || strcmp($action, "AUDIO") === 0) { + // Only DISPLAY and EMAIL have direct JSCalendar action values. + if (strcmp($action, "DISPLAY") === 0) { $alert->setAction("display"); } elseif (strcmp($action, "EMAIL") === 0) { $alert->setAction("email"); } + // Map ACKNOWLEDGED property (RFC 9074) to JSCalendar acknowledged timestamp + $acknowledged = $alarm->ACKNOWLEDGED; + if (AdapterUtil::isSetNotNullAndNotEmpty($acknowledged)) { + $acknowledgedDateTime = $acknowledged->getDateTime(); + $alert->setAcknowledged(date_format($acknowledgedDateTime, "Y-m-d\TH:i:s\Z")); + } + $jmapAlerts[$key] = $alert; $key++; } @@ -803,6 +1061,14 @@ public function setAlerts($alerts) foreach ($alerts as $id => $alert) { + if (is_array($alert)) { + $alert = Alert::fromJson($alert); // Convert array to Alert object if needed + } + + if (!($alert instanceof Alert)) { + continue; // Skip if not a valid Alert object + } + $this->iCalEvent->VEVENT->add("VALARM", []); $jsCalAction = $alert->getAction(); @@ -811,12 +1077,19 @@ public function setAlerts($alerts) if (strcmp($jsCalAction, "email") === 0) { $iCalAction = "EMAIL"; } else { + // Default to DISPLAY for "display", null, or any other action $iCalAction = "DISPLAY"; } $this->iCalEvent->VEVENT->VALARM[$alarmIndex]->add("ACTION", $iCalAction); $jsCalTrigger = $alert->getTrigger(); + + if (is_null($jsCalTrigger)) { // Trigger is mandatory, skip if missing + $alarmIndex++; + continue; + } + $triggerType = $jsCalTrigger->getType(); // Set the TRIGGER property. @@ -841,17 +1114,26 @@ public function setAlerts($alerts) $triggerValue = DateTime::createFromFormat("Y-m-d\TH:i:s\Z", $jsCalTrigger->getWhen()); // If the date time is false, it was probably not in UTC, which is the standard for both formats. - // Log and skip to the next alert. if (!$triggerValue) { - // TODO: Add the alert to the event as some sort of custom alert if this happens. $this->logger->error( "Unable to create date time for absolute trigger from value: " . $jsCalTrigger->getWhen() ); + // Preserve the invalid trigger data in a custom extension property + $this->iCalEvent->VEVENT->VALARM[$alarmIndex]->add( + "X-INVALID-TRIGGER-WHEN", + $jsCalTrigger->getWhen() + ); + // Set a safe default trigger so the alarm structure is valid + $this->iCalEvent->VEVENT->VALARM[$alarmIndex]->add( + "TRIGGER", + "-PT15M" + ); + + $alarmIndex++; continue; } - $this->iCalEvent->VEVENT->VALARM[$alarmIndex]->add("TRIGGER", $triggerValue, ["VALUE" => "DATE-TIME"]); } else { // The trigger type is not one of the two known ones. @@ -860,6 +1142,17 @@ public function setAlerts($alerts) continue; } + // Map acknowledged timestamp from JSCalendar to iCalendar ACKNOWLEDGED property + $acknowledged = $alert->getAcknowledged(); + + if (AdapterUtil::isSetNotNullAndNotEmpty($acknowledged)) { + // Convert UTC timestamp to DateTime for ACKNOWLEDGED property + $acknowledgedDateTime = DateTime::createFromFormat("Y-m-d\TH:i:s\Z", $acknowledged); + if ($acknowledgedDateTime) { + $this->iCalEvent->VEVENT->VALARM[$alarmIndex]->add("ACKNOWLEDGED", $acknowledgedDateTime); + } + } + $alarmIndex++; } } @@ -1017,8 +1310,7 @@ public function getRRule() break; case 'UNTIL': - //TODO: add the timezone of the current event as another property so that if it is something - // else than local (i.e. utc) the difference is added to the until value. + // Timezone handling is now implemented in the converter utility $jmapRecurrenceRule->setUntil( JSCalendarICalendarAdapterUtil::convertFromICalUntilToJmapUntil($value) ); @@ -1059,7 +1351,7 @@ public function setRRule($recurrenceRules) } $jsCalValue = $rec->getInterval(); - if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue)) { + if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue) && $jsCalValue != 1) { $iCalValue = JSCalendarICalendarAdapterUtil:: convertFromJmapIntervalToICalInterval($jsCalValue); @@ -1067,7 +1359,7 @@ public function setRRule($recurrenceRules) } $jsCalValue = $rec->getRscale(); - if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue)) { + if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue) && strtolower($jsCalValue) != 'gregorian') { $iCalValue = JSCalendarICalendarAdapterUtil:: convertFromJmapRScaleToICalRScale($jsCalValue); @@ -1075,7 +1367,7 @@ public function setRRule($recurrenceRules) } $jsCalValue = $rec->getSkip(); - if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue)) { + if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue) && strtolower($jsCalValue) != 'omit') { $iCalValue = JSCalendarICalendarAdapterUtil:: convertFromJmapSkipToICalSkip($jsCalValue); @@ -1083,7 +1375,7 @@ public function setRRule($recurrenceRules) } $jsCalValue = $rec->getFirstDayOfWeek(); - if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue)) { + if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue) && strtoupper($jsCalValue) != 'MO') { $iCalValue = JSCalendarICalendarAdapterUtil:: convertFromJmapFirstDayOfWeekToICalWKST($jsCalValue); @@ -1210,6 +1502,20 @@ public function setRecurrenceId($recurrenceId, $timeZone, $showWithoutTime) } } + public function getRecurrenceId() + { + $recurrenceId = $this->iCalEvent->VEVENT->{'RECURRENCE-ID'}; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($recurrenceId)) { + return null; + } + + $recurrenceIdDateTime = $recurrenceId->getDateTime(); + + // Return in JSCalendar format (local time) + return $recurrenceIdDateTime->format("Y-m-d\TH:i:s"); + } + public function getExDates() { $exDates = $this->iCalEvent->VEVENT->EXDATE; @@ -1220,19 +1526,17 @@ public function getExDates() $excludedRecurrenceIds = []; - $exDateValues = explode(",", $exDates->getValue()); - - foreach ($exDateValues as $exDate) { - if (!AdapterUtil::isSetNotNullAndNotEmpty($exDate)) { - continue; + // Go through each EXDATE property (there can be multiple) + foreach ($exDates as $exDate) { + // Parse comma-separated datetime values from each property + foreach ($exDate->getDateTimes() as $dateTime) { + $excludedRecurrenceIds[] = $dateTime->format("Y-m-d\TH:i:s"); } - - $recurrenceOverrideDateTime = new \DateTime($exDate); - $excludedRecurrenceIds[] = date_format($recurrenceOverrideDateTime, "Y-m-d\TH:i:s"); } return $excludedRecurrenceIds; } + public function setExDate($recurrenceId) { if (!AdapterUtil::isSetNotNullAndNotEmpty($recurrenceId)) { @@ -1250,7 +1554,7 @@ public function setExDate($recurrenceId) $dtStartString = $dtStart->getValue(); - if (strpos($dtStartString, "VALUE=DATE") !== false) { + if (isset($dtStart['VALUE']) && strtoupper((string) $dtStart['VALUE']) === 'DATE') { $exDateFormat = "Ymd"; } elseif (strpos($dtStartString, "Z") !== false) { $exDateFormat = "Ymd\THis\Z"; @@ -1284,6 +1588,109 @@ public function setExDate($recurrenceId) $this->iCalEvent->VEVENT->EXDATE = $setExDates; } + /** + * Get all RDATE values of the current VEVENT as JSCalendar recurrence override keys. + * + * An RDATE property with value type DATE or DATE-TIME converts to an empty PatchObject + * entry in "recurrenceOverrides". This method returns the converted recurrence ids. + * + * DATE values are returned in "Y-m-d" format. + * DATE-TIME values are returned in "Y-m-d\TH:i:s" format. + * + * @return array|null + */ + public function getRDates() + { + $rDates = $this->iCalEvent->VEVENT->RDATE; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($rDates)) { + return null; + } + + $includedRecurrenceIds = []; + + foreach ($rDates as $rDateProperty) { + $isDateValue = isset($rDateProperty['VALUE']) + && strtoupper((string) $rDateProperty['VALUE']) === 'DATE'; + + foreach ($rDateProperty->getDateTimes() as $dateTime) { + $includedRecurrenceIds[] = $isDateValue + ? $dateTime->format("Y-m-d") + : $dateTime->format("Y-m-d\TH:i:s"); + } + } + + return $includedRecurrenceIds; + } + + /** + * Add a recurrence instance as an RDATE property to the current VEVENT. + * + * Per the RFC5545, an empty PatchObject in + * "recurrenceOverrides" converts to an RDATE property. + * + * @param string $recurrenceId + * + * @return void + */ + public function setRDate($recurrenceId) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($recurrenceId)) { + return; + } + + $dtStart = $this->iCalEvent->VEVENT->DTSTART; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($dtStart)) { + return; + } + + $rDateFormat = null; + $inputFormat = null; + $timeZone = null; + + $dtStartString = $dtStart->getValue(); + + if (isset($dtStart['VALUE']) && strtoupper((string) $dtStart['VALUE']) === 'DATE') { + $rDateFormat = "Ymd"; + $inputFormat = "Y-m-d"; + } elseif (strpos($dtStartString, "Z") !== false) { + $rDateFormat = "Ymd\THis\Z"; + $inputFormat = "Y-m-d\TH:i:s"; + } else { + $rDateFormat = "Ymd\THis"; + $inputFormat = "Y-m-d\TH:i:s"; + + $timeZoneName = $this->getTimeZone(); + if (!is_null($timeZoneName)) { + $timeZone = new \DateTimeZone($timeZoneName); + } + } + + $rDateString = AdapterUtil::parseDateTime($recurrenceId, $inputFormat, $rDateFormat); + $rDate = new \DateTimeImmutable($rDateString, $timeZone); + + if (!AdapterUtil::isSetNotNullAndNotEmpty($this->iCalEvent->VEVENT->RDATE)) { + $this->iCalEvent->VEVENT->add("RDATE", $rDate); + + if ($rDateFormat === "Ymd") { + $this->iCalEvent->VEVENT->RDATE["VALUE"] = "DATE"; + } + + return; + } + + $setRDates = []; + + foreach ($this->iCalEvent->VEVENT->RDATE->getDateTimes() as $setRDate) { + $setRDates[] = $setRDate; + } + + $setRDates[] = $rDate; + + $this->iCalEvent->VEVENT->RDATE = $setRDates; + } + public function getParticipants() { $organizer = $this->iCalEvent->VEVENT->ORGANIZER; @@ -1293,13 +1700,31 @@ public function getParticipants() !AdapterUtil::isSetNotNullAndNotEmpty($organizer) && !AdapterUtil::isSetNotNullAndNotEmpty($attendees) ) { - return null; + return null; } $jmapParticipants = []; if (AdapterUtil::isSetNotNullAndNotEmpty($attendees)) { + // Sort attendees: process groups (CUTYPE=GROUP) before individuals + // This ensures memberOf references are valid when processing MEMBER parameters + $groupAttendees = []; + $individualAttendees = []; + foreach ($attendees as $attendee) { + $cutype = isset($attendee['CUTYPE']) ? $attendee['CUTYPE']->getValue() : null; + + if ($cutype === 'GROUP') { + $groupAttendees[] = $attendee; + } else { + $individualAttendees[] = $attendee; + } + } + + // Process groups first, then individuals + $sortedAttendees = array_merge($groupAttendees, $individualAttendees); + + foreach ($sortedAttendees as $attendee) { $attendeeValue = $attendee->getValue(); if (is_null($attendeeValue)) { @@ -1422,8 +1847,28 @@ private function addParticipantParameters($parameters, $participant) ); break; + case "DELEGATED-TO": + $participant->setDelegatedTo( + JSCalendarICalendarAdapterUtil::converFromICalDelegatedToToJmapDelegatedTo($param->getValue()) + ); + break; + case "DIR": - // TODO: implement me + // DIR parameter maps to JSCalendar "links" property with rel="describedby" + $links = $participant->getLinks(); + if (is_null($links)) { + $links = []; + } + + $dirLink = new Link(); + $dirLink->setType("Link"); + $dirLink->setHref($param->getValue()); + $dirLink->setRel("describedby"); + + $linkId = md5($param->getValue()); + $links[$linkId] = $dirLink; + + $participant->setLinks($links); break; case "LANGUAGE": @@ -1431,8 +1876,23 @@ private function addParticipantParameters($parameters, $participant) break; case "MEMBER": - // TODO: implement me. The conversion specs suggest to map participants that are groups first - // so we would need to do some filtering/sorting ahead of the conversions. + // MEMBER parameter indicates group membership + // Maps to JSCalendar "memberOf" property + // Groups are now processed first in getParticipants() to ensure valid conversion + $memberOf = $participant->getMemberOf(); + if (is_null($memberOf)) { + $memberOf = []; + } + + // MEMBER can contain multiple comma-separated values + $members = is_array($param->getValue()) ? $param->getValue() : [$param->getValue()]; + + foreach ($members as $member) { + // The member value is typically a mailto: URI or calendar user address + $memberOf[] = $member; + } + + $participant->setMemberOf($memberOf); break; case "PARTSTAT": @@ -1615,7 +2075,22 @@ private function extractParticipantParameters($participant) $parameters["SENT-BY"] = $jsCalValue; } - //TODO: implement "memberOf" and "links". + // Handle memberOf (converts to MEMBER parameter) + $jsCalValue = $participant->getMemberOf(); + if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue)) { + $parameters["MEMBER"] = $jsCalValue; + } + + // Handle links (DIR parameter for rel="describedby" links) + $jsCalValue = $participant->getLinks(); + if (AdapterUtil::isSetNotNullAndNotEmpty($jsCalValue)) { + foreach ($jsCalValue as $linkId => $link) { + if ($link->getRel() === "describedby") { + $parameters["DIR"] = $link->getHref(); + break; + } + } + } return $parameters; } @@ -1640,6 +2115,228 @@ public function setPriority($priority) $this->iCalEvent->VEVENT->add("PRIORITY", $priority); } + /** + * Get the iCalendar METHOD property. + * Indicates the type of iTIP message (REQUEST, REPLY, CANCEL, etc.). + * + * @return string|null Method in lowercase (e.g., "request", "reply"), or null if not set + */ + public function getMethod() + { + $method = $this->iCalEvent->METHOD; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($method)) { + return null; + } + + return strtolower($method->getValue()); + } + + /** + * Set the iCalendar METHOD property. + * Required for calendar invitations and scheduling messages. + * + * @param string $method Method value (e.g., "request", "reply", "cancel", "publish") + * @return void + */ + public function setMethod($method) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($method)) { + return; + } + + $iCalMethod = strtoupper($method); + + if (isset($this->iCalEvent->METHOD)) { + $this->iCalEvent->METHOD = $iCalMethod; + } else { + $this->iCalEvent->add('METHOD', $iCalMethod); + } + } + + /** + * Get the reply-to address for scheduling responses. + * Maps iCalendar REPLY-URL to JSCalendar "replyTo" property. + * + * @return array|null Array with "imip" key containing mailto: URL, or null if not set + */ + public function getReplyTo() + { + $replyUrl = $this->iCalEvent->VEVENT->{'REPLY-URL'}; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($replyUrl)) { + return null; + } + + return ["imip" => $replyUrl->getValue()]; + } + + /** + * Set the reply-to address for scheduling responses. + * Maps JSCalendar "replyTo" to iCalendar REPLY-URL property. + * + * @param array $replyTo Array with "imip" key (e.g., ["imip" => "mailto:rsvp@example.com"]) + * @return void + */ + public function setReplyTo($replyTo) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($replyTo)) { + return; + } + + if ($replyTo instanceof \stdClass) { + $replyTo = (array) $replyTo; + } + + if (is_array($replyTo) && isset($replyTo["imip"])) { + $this->iCalEvent->VEVENT->add('REPLY-URL', $replyTo["imip"]); + } + } + + /** + * Get the scheduling request status codes. + * Maps iCalendar REQUEST-STATUS to JSCalendar "requestStatus" property. + * + * @return array|null Array of status codes, or null if not set + */ + public function getRequestStatus() + { + $requestStatus = $this->iCalEvent->VEVENT->{'REQUEST-STATUS'}; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($requestStatus)) { + return null; + } + + $statuses = []; + + foreach ($requestStatus as $status) { + $statuses[] = $status->getValue(); + } + + return $statuses; + } + + /** + * Set scheduling request status codes. + * Maps JSCalendar "requestStatus" to iCalendar REQUEST-STATUS properties. + * + * @param array $statuses Array of status code strings (e.g., ["2.0;Success", "4.0;Event not found"]) + * @return void + */ + public function setRequestStatus($statuses) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($statuses)) { + return; + } + + foreach ($statuses as $status) { + $this->iCalEvent->VEVENT->add('REQUEST-STATUS', $status); + } + } + + public function getUrl() + { + $url = $this->iCalEvent->VEVENT->URL; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($url)) { + return null; + } + + return $url->getValue(); + } + + public function setUrl($url) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($url)) { + return; + } + + $this->iCalEvent->VEVENT->add('URL', $url); + } + + /** + * Get related events as JSCalendar relatedTo property. + * Maps iCalendar RELATED-TO properties with their relationship types. + * + * @return array|null Array of relation data indexed by UID, or null if none exist + */ + public function getRelatedTo() + { + $relatedTo = $this->iCalEvent->VEVENT->{'RELATED-TO'}; + + if (!AdapterUtil::isSetNotNullAndNotEmpty($relatedTo)) { + return null; + } + + $jmapRelatedTo = []; + + foreach ($relatedTo as $relation) { + $uid = $relation->getValue(); + + $relationData = []; + + // Check for RELTYPE parameter + if (isset($relation['RELTYPE'])) { + $relType = strtolower($relation['RELTYPE']->getValue()); + $relationData['relation'][$relType] = true; + } else { + // Default is PARENT + $relationData['relation']['parent'] = true; + } + + $jmapRelatedTo[$uid] = $relationData; + } + + return $jmapRelatedTo; + } + + /** + * Set related events from JSCalendar relatedTo property. + * Creates iCalendar RELATED-TO properties with appropriate relationship types. + * + * @param array $relatedTo Array of relation data indexed by UID + * + * @return void + */ + public function setRelatedTo($relatedTo) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($relatedTo)) { + return; + } + + foreach ($relatedTo as $uid => $relationData) { + $parameters = []; + + // Handle both Relation objects and array format + if ($relationData instanceof \OpenXPort\Jmap\Calendar\Relation) { + // It's a Relation object - get the relation data from it + $relations = $relationData->getRelation(); + } elseif (isset($relationData['relation'])) { + // It's an array - use it directly + $relations = $relationData['relation']; + } else { + continue; + } + + if (!empty($relations)) { + $relationTypes = array_keys(array_filter($relations)); + if (!empty($relationTypes)) { + // Use the first relation type + $relType = strtoupper($relationTypes[0]); + if ($relType !== 'PARENT') { + $parameters['RELTYPE'] = $relType; + } + } + } + + if (empty($parameters)) { + $this->iCalEvent->VEVENT->add('RELATED-TO', $uid); + } else { + $this->iCalEvent->VEVENT->add('RELATED-TO', $uid, $parameters); + } + } + } + public function getAttachments() { $attachments = $this->iCalEvent->VEVENT->ATTACH; @@ -1676,12 +2373,24 @@ public function getAttachments() } if ( + array_key_exists("LABEL", $attach->parameters) && + AdapterUtil::isSetNotNullAndNotEmpty($attach->parameters["LABEL"]) + ) { + $link->setTitle($attach->parameters["LABEL"]->getValue()); + } elseif ( array_key_exists("FILENAME", $attach->parameters) && AdapterUtil::isSetNotNullAndNotEmpty($attach->parameters["FILENAME"]) ) { $link->setTitle($attach->parameters["FILENAME"]->getValue()); } + if ( + array_key_exists("SIZE", $attach->parameters) && + AdapterUtil::isSetNotNullAndNotEmpty($attach->parameters["SIZE"]) + ) { + $link->setSize((int) $attach->parameters["SIZE"]->getValue()); + } + array_push($links, $link); } @@ -1807,31 +2516,112 @@ public function setAttachments($links) } } + // Use LABEL (RFC 7986 standard) if (AdapterUtil::isSetNotNullAndNotEmpty($link->getTitle())) { - $data["parameters"]["FILENAME"] = $link->getTitle(); + $data["parameters"]["LABEL"] = $link->getTitle(); + } + + // Convert numeric size to string for SIZE parameter (RFC 8607) + if (AdapterUtil::isSetNotNullAndNotEmpty($link->getSize())) { + $data["parameters"]["SIZE"] = (string) $link->getSize(); } + // VObject handles empty parameters array automatically $this->iCalEvent->VEVENT->add( "ATTACH", $data["value"], $data["parameters"] ); + } + } - /* TODO: check if necessary v v v - if (empty($data["parameters"])) { - $this->iCalEvent->VEVENT->add( - "ATTACH", - $data["value"] - ); + /** + * Get virtual meeting locations from CONFERENCE properties. + * Converts iCalendar CONFERENCE properties to JSCalendar VirtualLocation objects. + * + */ + public function getVirtualLocations() + { + $conferences = $this->iCalEvent->VEVENT->CONFERENCE; - } else { - $this->iCalEvent->VEVENT->add( - "ATTACH", - $data["value"], - $data["parameters"] - ); + if (!AdapterUtil::isSetNotNullAndNotEmpty($conferences)) { + return null; + } + + $virtualLocations = []; + $key = 1; + + // Convert each CONFERENCE property to a VirtualLocation object + foreach ($conferences as $conference) { + $virtualLocation = new VirtualLocation(); + $virtualLocation->setType("VirtualLocation"); + + $uri = $conference->getValue(); + if (AdapterUtil::isSetNotNullAndNotEmpty($uri)) { + $virtualLocation->setUri($uri); + } + + if (isset($conference['LABEL'])) { // Map LABEL parameter to name + $virtualLocation->setName($conference['LABEL']->getValue()); + } + + if (isset($conference['FEATURE'])) { + $features = []; // Map FEATURE parameter to features (audio, video, chat, etc.) + foreach ($conference['FEATURE']->getParts() as $feature) { + $features[strtolower($feature)] = true; + } + $virtualLocation->setFeatures($features); + } + + $virtualLocations[$key] = $virtualLocation; + $key++; + } + + return $virtualLocations; + } + + /** + * Set virtual meeting locations as CONFERENCE properties. + * Converts JSCalendar VirtualLocation objects to iCalendar CONFERENCE properties. + * + * @param array $virtualLocations Array of VirtualLocation objects + * + * @return void + */ + public function setVirtualLocations($virtualLocations) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($virtualLocations)) { + return; + } + + foreach ($virtualLocations as $virtualLocation) { + $uri = $virtualLocation->getUri(); + + if (!AdapterUtil::isSetNotNullAndNotEmpty($uri)) { + continue; } - */ + + $parameters = []; + + $name = $virtualLocation->getName(); + if (AdapterUtil::isSetNotNullAndNotEmpty($name)) { + $parameters['LABEL'] = $name; + } + + $features = $virtualLocation->getFeatures(); + if (AdapterUtil::isSetNotNullAndNotEmpty($features)) { + $featureList = []; + foreach ($features as $feature => $enabled) { + if ($enabled) { + $featureList[] = strtoupper($feature); + } + } + if (!empty($featureList)) { + $parameters['FEATURE'] = $featureList; + } + } + + $this->iCalEvent->VEVENT->add('CONFERENCE', $uri, $parameters); } } } diff --git a/src/adapter/JSContactVCardAdapter.php b/src/adapter/JSContactVCardAdapter.php index b8ee281..07d085a 100644 --- a/src/adapter/JSContactVCardAdapter.php +++ b/src/adapter/JSContactVCardAdapter.php @@ -2,32 +2,41 @@ namespace OpenXPort\Adapter; -use InvalidArgumentException; -use OpenXPort\Jmap\JSContact\Address; -use OpenXPort\Jmap\JSContact\Anniversary; -use OpenXPort\Jmap\JSContact\ContactLanguage; -use OpenXPort\Jmap\JSContact\EmailAddress; -use OpenXPort\Jmap\JSContact\File; +use OpenXPort\Jmap\JSContact\ContactCard; use OpenXPort\Jmap\JSContact\Name; use OpenXPort\Jmap\JSContact\NameComponent; -use OpenXPort\Jmap\JSContact\OnlineService; +use OpenXPort\Jmap\JSContact\Nickname; use OpenXPort\Jmap\JSContact\Organization; -use OpenXPort\Jmap\JSContact\PersonalInformation; +use OpenXPort\Jmap\JSContact\Title; +use OpenXPort\Jmap\JSContact\Note; +use OpenXPort\Jmap\JSContact\EmailAddress; use OpenXPort\Jmap\JSContact\Phone; +use OpenXPort\Jmap\JSContact\OnlineService; +use OpenXPort\Jmap\JSContact\Address; +use OpenXPort\Jmap\JSContact\AddressComponent; +use OpenXPort\Jmap\JSContact\Anniversary; use OpenXPort\Jmap\JSContact\Relation; -use OpenXPort\Jmap\JSContact\Resource; +use OpenXPort\Jmap\JSContact\LanguagePref; +use OpenXPort\Jmap\JSContact\PersonalInformation; use OpenXPort\Jmap\JSContact\SpeakToAs; -use OpenXPort\Jmap\JSContact\StreetComponent; -use OpenXPort\Jmap\JSContact\Title; +use OpenXPort\Jmap\JSContact\Pronouns; +use OpenXPort\Jmap\JSContact\Directory; +use OpenXPort\Jmap\JSContact\Link; +use OpenXPort\Jmap\JSContact\Media; +use OpenXPort\Jmap\JSContact\SchedulingAddress; +use OpenXPort\Jmap\JSContact\CryptoKey; +use OpenXPort\Jmap\JSContact\Calendar; +use OpenXPort\Jmap\JSContact\Author; use OpenXPort\Util\AdapterUtil; -use OpenXPort\Util\JSContactVCardAdapterUtil; +use OpenXPort\Util\JSContactVCardAdapterUtil as Util; use OpenXPort\Util\Logger; use Sabre\VObject; +use Sabre\VObject\Property; use Sabre\VObject\ParseException; /** * Generic adapter to convert between vCard <-> JSContact. - * Strictly follows the "JSContact: Converting from and to vCard" spec + * Strictly follows the "JSContact: Converting from and to vCard" spec (RFC 9555) */ class JSContactVCardAdapter extends AbstractAdapter { @@ -36,18 +45,29 @@ class JSContactVCardAdapter extends AbstractAdapter /** @var VObject\Component\VCard */ protected $vCard; + /** @var string */ + protected $rawVCard; + protected $vCardChildren = []; /** * @var array OXP-specific properties not present in vCard or JSContact: * * addressBookId * * vCardProps - * https://www.ietf.org/archive/id/draft-ietf-calext-jscontact-vcard-06.html#name-property-vcardprops + * https://www.ietf.org/archive/id/draft-ietf-calext-jscontact-vCard-06.html#name-property-vCardprops * * vCardParams array>> - * https://www.ietf.org/archive/id/draft-ietf-calext-jscontact-vcard-06.html#name-property-vcardparams + * https://www.ietf.org/archive/id/draft-ietf-calext-jscontact-vCard-06.html#name-property-vCardparams */ protected $oxpProperties = []; + /** @var bool When true, plain text birth/death places are kept as a full address string. */ + protected $placeTextAsFullAddress = true; + + /** @var bool When true, vCard ANNIVERSARY is treated as a wedding anniversary. */ + protected $mapVcardAnniversaryToWedding = true; + + protected $addressBookId = null; + /** * @var string|null Config option that determines the behavior of the adapter when encountering 'broken' * vCards. Possible values are: @@ -65,7 +85,6 @@ class JSContactVCardAdapter extends AbstractAdapter */ protected $dumpInvalidVCards; - /** * Constructor of this class * @@ -89,7 +108,7 @@ public function __construct($parsingConfig = 'strict', $dumpInvalidVCards = fals $this->dumpInvalidVCards = $dumpInvalidVCards; $this->logger->info( - "Using adapter config options: 'vCardParsing' => '" + "Using RFC 9555 compliant adapter with config: 'vCardParsing' => '" . $this->parsingConfig . "', 'dumpInvalidVCards' => " . ($this->dumpInvalidVCards ? "true" : "false") @@ -98,58 +117,16 @@ public function __construct($parsingConfig = 'strict', $dumpInvalidVCards = fals } /** - * Collect vCard properties of a certain type - * - * @return array containing vCard properties that are truly set. - */ - private function collectVcardProps($vCardPropStr) - { - $res = []; - - if (in_array($vCardPropStr, $this->vCardChildren)) { - $vCardPropsFound = $this->vCard->{$vCardPropStr}; - - foreach ($vCardPropsFound as $vCardProp) { - if (isset($vCardProp)) { - array_push($res, $vCardProp); - } - } - } - - return $res; - } - - /* - * Check if the currently unsupported vCard parameter ALTID is present - * If yes, then provide an error log with some information that it is not supported - * TODO I began implementing this as a PoC but did not take the time to finish it, - * because it is unlikely we ever need this :P + * Reset the content in the adapter. */ - public function convertAltId($vCardProp, $jsCardPropPath) + public function reset() { - if ( - isset($vCardProp['ALTID']) - && !empty($vCardProp['ALTID']) - ) { - $this->logger->error("Preserving ALTID as custom property currently not supported"); - if (!array_key_exists("vCardParams", $this->oxpProperties)) { - $this->oxpProperties["vCardParams"] = []; - } - - // Create the necessary nested array keys - $curArrayPtr = &$this->oxpProperties["vCardParams"]; - foreach ($jsCardPropPath as $arrayPath) { - if (!array_key_exists($arrayPath, $curArrayPtr)) { - $curArrayPtr[$arrayPath] = []; - } - $curArrayPtr = &$curArrayPtr[$arrayPath]; - } - - array_push($curArrayPtr, $value); - } + $this->vCard = new VObject\Component\VCard(); + $this->rawVCard = null; + $this->oxpProperties = array(); + $this->vCardChildren = array(); } - /** * Return the contents of this adapter as a hash. * @@ -162,8 +139,10 @@ public function convertAltId($vCardProp, $jsCardPropPath) public function getAsHash() { return array( - "vCard" => $this->vCard->serialize(), - "oxpProperties" => $this->oxpProperties + 'vCard' => $this->getVCard(), + 'oxpProperties' => array( + 'addressBookId' => $this->addressBookId, + ), ); } @@ -177,21 +156,12 @@ public function setFromHash($cHash) if (!array_key_exists('oxpProperties', $cHash)) { return; } + if (array_key_exists('addressBookId', $cHash["oxpProperties"])) { $this->oxpProperties["addressBookId"] = $cHash["oxpProperties"]["addressBookId"]; } } - /** - * Reset the content in the adapter. - */ - public function reset() - { - $this->vCardChildren = []; - $this->oxpProperties = []; - $this->vCard = new VObject\Component\VCard(); - } - /** * Getter for this class' $vCard property * @@ -204,7 +174,6 @@ public function getVCard() if (!AdapterUtil::isSetNotNullAndNotEmpty($this->vCard)) { return null; } - return $this->vCard->serialize(); } @@ -217,6 +186,8 @@ public function getVCard() */ public function setVCard($vCardString) { + $this->rawVCard = $vCardString; + try { $this->vCard = VObject\Reader::read($vCardString); } catch (ParseException $e) { @@ -247,25 +218,23 @@ public function setVCard($vCardString) * @param ParseException $e The exception thrown from the first try of running * VObject\Reader::read() without 'OPTION_IGNORE_INVALID_LINES'. */ - protected function setBrokenVCard($vCardString, $e): void + protected function setBrokenVCard($vCardString, $e) { switch ($this->parsingConfig) { case 'strict': $this->handleVCardDump($vCardString); throw $e; break; - case 'ignoreInvalidLines': try { $this->vCard = VObject\Reader::read( $vCardString, VObject\Reader::OPTION_IGNORE_INVALID_LINES ); - } catch (ParseException $p) { + } catch (VObject\ParseException $p) { $this->handleVCardDump($vCardString); throw $p; } - break; case 'ignoreInvalidVCards': @@ -274,13 +243,10 @@ protected function setBrokenVCard($vCardString, $e): void $vCardString, VObject\Reader::OPTION_IGNORE_INVALID_LINES ); - } catch (VObject\ParseException $e) { + } catch (ParseException $e) { $this->handleVCardDump($vCardString); - // TODO: Setting the vCard to null maybe a bit of a dirty workaround. - // Returning true/false depending on the result might be a bit better. $this->vCard = null; } - break; default: @@ -290,6 +256,23 @@ protected function setBrokenVCard($vCardString, $e): void } } + public function getAddressBookId(ContactCard $card) + { + if (!array_key_exists('addressBookId', $this->oxpProperties)) { + $this->logger->warning( + "addressBookId does not exist for card " . $card->getUid() + ); + return null; + } + + return $this->oxpProperties['addressBookId']; + } + + public function setAddressBookId($addressBookId) + { + $this->oxpProperties["addressBookId"] = $addressBookId; + } + /** * If $dumpInvalidVCards is set to true, the vCard string is dumped using the logger. * @@ -304,4147 +287,3067 @@ protected function handleVCardDump($vCardString) $this->logger->warning("Dumping vCard:\n$vCardString"); } - public function getAddressBookId() + /** + * Writes a vCard property, removing any existing copies of it first so there's never more than one. + * + * @param string $name + * @param mixed $value + * @param array $params + */ + protected function addSingleProperty($name, $value, array $params = array()) { - if (!array_key_exists('addressBookId', $oxpProperties)) { - $this->logger->warning("addressBookId does not exist for card " . $this->getUid()); - return; - }; + if (isset($this->vCard->{$name})) { + foreach ($this->vCard->{$name} as $prop) { + $this->vCard->remove($prop); + } + } - return $this->oxpProperties["addressBookId"]; + $this->vCard->add($name, $value, $params); } - public function setAddressBookId($addressBookId) + /** + * Returns all non-empty values for a repeatable vCard property as an array. + * + * @param string $name + * @return array + */ + protected function getPropertyValues($name) { - $this->oxpProperties["addressBookId"] = $addressBookId; - } + $result = array(); + $props = $this->vCard->{$name}; - public function getOnlineServices() - { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; + if (!AdapterUtil::isSetAndNotNull($props) || empty($props)) { + return $result; } - $jsContactOnlineProperty = null; - - foreach ($this->collectVcardProps("IMPP") as $vCardImppProperty) { - $vCardImppPropertyValue = $vCardImppProperty->getValue(); - - if (isset($vCardImppPropertyValue) && !empty($vCardImppPropertyValue)) { - $jsContactImppEntry = new OnlineService($vCardImppPropertyValue, "impp"); - - if (isset($vCardImppProperty['PREF']) && !empty($vCardImppProperty['PREF'])) { - $jsContactImppEntry->setPref($vCardImppProperty['PREF']); - } - - $jsContactImppEntry->setContexts(JSContactVCardAdapterUtil::convertFromVCardType($vCardImppProperty)); - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the IMPP property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardImppPropertyValue)] = $jsContactImppEntry; + foreach ($props as $prop) { + $value = trim((string) $prop); + if ($value !== '') { + $result[] = $value; } } - foreach ($this->collectVcardProps("SOCIALPROFILE") as $vCardSocialProperty) { - $vCardSocialPropertyValue = $vCardSocialProperty->getValue(); + return $result; + } - if (isset($vCardSocialPropertyValue) && !empty($vCardSocialPropertyValue)) { - if ( - isset($vCardSocialProperty['VALUE']) && - !empty($vCardSocialProperty['VALUE']) && - $vCardSocialProperty['VALUE'] == "text" - ) { - $jsContactSocialEntry = new OnlineService($vCardSocialPropertyValue, "username"); - } else { - $jsContactSocialEntry = new OnlineService($vCardSocialPropertyValue, "uri"); - } + /** + * Returns the value of a single-valued vCard property as a trimmed string, or null if absent or empty. + * + * @param string $name + * @return string|null + */ + protected function getSinglePropertyValue($name) + { + $prop = $this->vCard->{$name}; + if (!AdapterUtil::isSetAndNotNull($prop)) { + return null; + } - if (isset($vCardSocialProperty['PREF']) && !empty($vCardSocialProperty['PREF'])) { - $jsContactSocialEntry->setPref($vCardSocialProperty['PREF']); - } + $value = trim((string) $prop); + return $value === '' ? null : $value; + } - if (isset($vCardSocialProperty['SERVICE-TYPE']) && !empty($vCardSocialProperty['SERVICE-TYPE'])) { - $jsContactSocialEntry->setService($vCardSocialProperty['SERVICE-TYPE']); - } + /** + * Writes the five standard name components to the vCard N property. + * Any component that is null or empty is written as an empty string. + * + * @param string|null $lastName + * @param string|null $firstName + * @param string|null $middleName + * @param string|null $prefix + * @param string|null $suffix + */ + protected function setN($lastName, $firstName, $middleName, $prefix, $suffix) + { + $lastName = AdapterUtil::isSetAndNotNull($lastName) && $lastName !== '' ? $lastName : ''; + $firstName = AdapterUtil::isSetAndNotNull($firstName) && $firstName !== '' ? $firstName : ''; + $middleName = AdapterUtil::isSetAndNotNull($middleName) && $middleName !== '' ? $middleName : ''; + $prefix = AdapterUtil::isSetAndNotNull($prefix) && $prefix !== '' ? $prefix : ''; + $suffix = AdapterUtil::isSetAndNotNull($suffix) && $suffix !== '' ? $suffix : ''; - $jsContactSocialEntry->setContexts( - JSContactVCardAdapterUtil::convertFromVCardType($vCardSocialProperty) - ); + $this->addSingleProperty('N', array($lastName, $firstName, $middleName, $prefix, $suffix)); + } - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the IMPP property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardSocialPropertyValue)] = $jsContactSocialEntry; - } + /** + * Returns the given (first) name from the vCard N property, or null if not set. + * + * @return string|null + */ + protected function getFirstName() + { + $n = $this->vCard->N; + if (AdapterUtil::isSetAndNotNull($n)) { + $parts = $n->getParts(); + return isset($parts[1]) ? $parts[1] : null; } + return null; + } - return $jsContactOnlineProperty; + /** + * Returns the family (last) name from the vCard N property, or null if not set. + * + * @return string|null + */ + protected function getLastName() + { + $n = $this->vCard->N; + if (AdapterUtil::isSetAndNotNull($n)) { + $parts = $n->getParts(); + return isset($parts[0]) ? $parts[0] : null; + } + return null; } - // TODO: Everywhere here setLabel() needs to be replaced by setType() - // by following the specs here: - // https://datatracker.ietf.org/doc/html/draft-ietf-calext-jscontact-02#section-2.3.3 - // and here: - // https://datatracker.ietf.org/doc/html/draft-ietf-calext-jscontact-vcard-01#section-3.5.2 - // This TODO also affects all setter methods in this adapter that map JSContact's "online" - // property to various vCard properties (hint: look at this method's docstring to - // find the affected vCard property names) /** - * This function translates all necessary vCard properties to the JSContact "online" property + * Returns the middle name from the vCard N property, or null if absent or empty. * - * The vCard properties that map to "online" are: - * * SOURCE - * * IMPP - * * LOGO - * * CONTACT-URI - * * ORG-DIRECTORY - * * SOUND - * * URL - * * KEY - * * FBURL - * * CALADRURI - * * CALURI + * @return string|null + */ + protected function getMiddlename() + { + $n = $this->vCard->N; + if (AdapterUtil::isSetAndNotNull($n)) { + $parts = $n->getParts(); + if (isset($parts[2])) { + $middle = $parts[2]; + if (AdapterUtil::isSetAndNotNull($middle) && $middle !== '') { + return $middle; + } + } + } + return null; + } + + /** + * Returns the honorific prefix (e.g. "Dr.", "Mr.") from the vCard N property, or null. * - * @return array|null The "online" JSContact property as a map of IDs to Resource objects - * @deprecated replaced by getOnlineServices + * @return string|null */ - public function getOnline() + protected function getPrefix() { - trigger_error( - "Called method " . __METHOD__ . " uses outdated property, use onlineServices instead.", - E_USER_DEPRECATED - ); + $n = $this->vCard->N; + if (AdapterUtil::isSetAndNotNull($n)) { + $parts = $n->getParts(); + return isset($parts[3]) ? $parts[3] : null; + } + return null; + } - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; + /** + * Returns the honorific suffix (e.g. "Jr.", "PhD") from the vCard N property, or null. + * + * @return string|null + */ + protected function getSuffix() + { + $n = $this->vCard->N; + if (AdapterUtil::isSetAndNotNull($n)) { + $parts = $n->getParts(); + return isset($parts[4]) ? $parts[4] : null; } + return null; + } - $jsContactOnlineProperty = null; + /** + * Writes the display name to the vCard FN property if the value is non-empty. + * + * @param string $displayname + */ + protected function setDisplayname($displayname) + { + if (AdapterUtil::isSetAndNotNull($displayname) && $displayname !== '') { + $this->addSingleProperty('FN', $displayname); + } + } - // SOURCE property mapping - if (in_array("SOURCE", $this->vCardChildren)) { - $vCardSourceProperties = $this->vCard->SOURCE; + /** + * Returns the display name from the vCard FN property, or null if absent. + * + * @return string|null + */ + protected function getDisplayname() + { + $fn = $this->vCard->FN; + if (AdapterUtil::isSetAndNotNull($fn) && !empty($fn)) { + return (string) $fn; + } + return null; + } - foreach ($vCardSourceProperties as $vCardSourceProperty) { - if (isset($vCardSourceProperty)) { - $vCardSourcePropertyValue = $vCardSourceProperty->getValue(); + /** + * This function maps the "uid" JSContact property to the UID vCard property + * + * @param ContactCard $card + */ + public function setUid(ContactCard $card) + { + $uid = $card->getUid(); - // Only if the vCard SOURCE property indeed has a value, create a - // corresponding entry in the JSContact "online" property - if (isset($vCardSourcePropertyValue) && !empty($vCardSourcePropertyValue)) { - $jsContactSourceEntry = new Resource(); - $jsContactSourceEntry->setAtType("Resource"); - $jsContactSourceEntry->setType('uri'); - $jsContactSourceEntry->setLabel('source'); - $jsContactSourceEntry->setResource($vCardSourcePropertyValue); + if (!isset($uid) || empty($uid)) { + return; + } - if (isset($vCardSourceProperty['PREF']) && !empty($vCardSourceProperty['PREF'])) { - $jsContactSourceEntry->setPref($vCardSourceProperty['PREF']); - } + $this->addSingleProperty('UID', $uid); + } - if (isset($vCardSourceProperty['MEDIATYPE']) && !empty($vCardSourceProperty['MEDIATYPE'])) { - $jsContactSourceEntry->setMediaType($vCardSourceProperty['MEDIATYPE']); - } + /** + * This function maps the vCard "UID" property to the ContactCard. + * + * @param ContactCard $card + */ + public function getUid(ContactCard $card) + { + $vCardUidProperty = $this->vCard->UID; - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the SOURCE property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardSourcePropertyValue)] = $jsContactSourceEntry; - } - } + if (AdapterUtil::isSetAndNotNull($vCardUidProperty)) { + $vCardUidPropertyValue = trim((string) $vCardUidProperty); + if (Util::isNonEmptyString($vCardUidPropertyValue)) { + $card->setUid($vCardUidPropertyValue); + return; } } - // IMPP property mapping - if (in_array("IMPP", $this->vCardChildren)) { - $vCardImppProperties = $this->vCard->IMPP; + $card->setUid(null); + } - foreach ($vCardImppProperties as $vCardImppProperty) { - if (isset($vCardImppProperty)) { - $vCardImppPropertyValue = $vCardImppProperty->getValue(); + /** + * Writes the ContactCard's last-modified timestamp to the vCard REV property. + * + * @param ContactCard $card + */ + public function setUpdated(ContactCard $card) + { + $updated = $card->getUpdated(); + $vRev = Util::parseDateTimeToVcardTimestamp($updated); + if ($vRev !== null) { + $this->addSingleProperty('REV', $vRev); + } + } - if (isset($vCardImppPropertyValue) && !empty($vCardImppPropertyValue)) { - $jsContactImppEntry = new Resource(); - $jsContactImppEntry->setAtType("Resource"); - $jsContactImppEntry->setType("username"); - $jsContactImppEntry->setLabel("XMPP"); - $jsContactImppEntry->setResource($vCardImppPropertyValue); + /** + * Reads the vCard REV timestamp and stores it as the ContactCard's updated date. + * + * @param ContactCard $card + */ + public function getUpdated(ContactCard $card) + { + $rev = $this->vCard->REV; + if (!AdapterUtil::isSetAndNotNull($rev)) { + return; + } - if (isset($vCardImppProperty['PREF']) && !empty($vCardImppProperty['PREF'])) { - $jsContactImppEntry->setPref($vCardImppProperty['PREF']); - } + $value = trim((string) $rev); + $parsed = Util::parseTimestampDateTime($value); + if ($parsed !== null) { + $card->setUpdated($parsed); + } + } - if (isset($vCardImppProperty['TYPE']) && !empty($vCardImppProperty['TYPE'])) { - $jsContactImppContexts = []; - - foreach ($vCardImppProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactImppContexts['private'] = true; - break; - - case 'work': - $jsContactImppContexts['work'] = true; - break; - - case 'other': - $jsContactImppContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property IMPP: " . $paramValue - ); - break; - } - - $jsContactImppEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactImppContexts) - ? $jsContactImppContexts - : null - ); - } - } + /** + * Writes the ContactCard creation timestamp to the vCard CREATED property. + * + * @param ContactCard $card + */ + public function setCreated(ContactCard $card) + { + $created = $card->getCreated(); + $vCreated = Util::parseDateTimeToVcardTimestamp($created); + if ($vCreated !== null) { + $this->addSingleProperty('CREATED', $vCreated); + } + } - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the IMPP property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardImppPropertyValue)] = $jsContactImppEntry; - } - } - } + /** + * Reads the vCard CREATED timestamp and stores it on the ContactCard. + * + * @param ContactCard $card + */ + public function getCreated(ContactCard $card) + { + $created = $this->vCard->__get('CREATED'); + if (!AdapterUtil::isSetAndNotNull($created)) { + return; } - // LOGO property mapping - if (in_array("LOGO", $this->vCardChildren)) { - $vCardLogoProperties = $this->vCard->LOGO; + $value = trim((string) $created); + if ($value === '') { + return; + } - foreach ($vCardLogoProperties as $vCardLogoProperty) { - if (isset($vCardLogoProperty)) { - $vCardLogoPropertyValue = $vCardLogoProperty->getValue(); + $parsed = Util::parseTimestampDateTime($value); + if ($parsed !== null) { + $card->setCreated($parsed); + } + } - if (isset($vCardLogoPropertyValue) && !empty($vCardLogoPropertyValue)) { - $jsContactLogoEntry = new Resource(); - $jsContactLogoEntry->setAtType("Resource"); - $jsContactLogoEntry->setType("uri"); - $jsContactLogoEntry->setLabel("logo"); - $jsContactLogoEntry->setResource($vCardLogoPropertyValue); + /** + * Writes the ContactCard product ID to the vCard PRODID property. + * + * @param ContactCard $card + */ + public function setProdId(ContactCard $card) + { + $prodId = $card->getProdId(); + if (is_string($prodId) && $prodId !== '') { + $this->addSingleProperty('PRODID', $prodId); + } + } - if (isset($vCardLogoProperty['PREF']) && !empty($vCardLogoProperty['PREF'])) { - $jsContactLogoEntry->setPref($vCardLogoProperty['PREF']); - } + /** + * Reads the vCard PRODID and stores it on the ContactCard. + * + * @param ContactCard $card + */ + public function getProdId(ContactCard $card) + { + $value = $this->getSinglePropertyValue('PRODID'); + if ($value !== null) { + $card->setProdId($value); + } + } - if (isset($vCardLogoProperty['TYPE']) && !empty($vCardLogoProperty['TYPE'])) { - $jsContactLogoContexts = []; - - foreach ($vCardLogoProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactLogoContexts['private'] = true; - break; - - case 'work': - $jsContactLogoContexts['work'] = true; - break; - - case 'other': - $jsContactImppContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property LOGO: " . $paramValue - ); - break; - } - - $jsContactLogoEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactLogoContexts) - ? $jsContactLogoContexts - : null - ); - } - } + /** + * Writes the contact kind to the vCard KIND property. + * If the card has members, KIND is always forced to "group". + * + * @param ContactCard $card + */ + public function setKind(ContactCard $card) + { + $members = $card->getMembers(); + if (is_array($members) && !empty($members)) { + $this->addSingleProperty('KIND', 'group'); + return; + } - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the LOGO property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardLogoPropertyValue)] = $jsContactLogoEntry; - } - } - } + $kind = $card->getKind(); + if (is_string($kind) && $kind !== '') { + $this->addSingleProperty('KIND', $kind); } + } - // CONTACT-URI property mapping - if (in_array("CONTACT-URI", $this->vCardChildren)) { - $vCardContactUriProperties = $this->vCard->__get("CONTACT-URI"); + /** + * Reads the vCard KIND and stores it on the ContactCard. + * Skips "group" since group membership is handled separately. + * + * @param ContactCard $card + */ + public function getKind(ContactCard $card) + { + $kind = $this->vCard->KIND; + if (!AdapterUtil::isSetAndNotNull($kind)) { + return; + } - foreach ($vCardContactUriProperties as $vCardContactUriProperty) { - if (isset($vCardContactUriProperty)) { - $vCardContactUriPropertyValue = $vCardContactUriProperty->getValue(); + $value = trim((string) $kind); + if ($value !== '' && $value !== 'group') { + $card->setKind($value); + } + } - if (isset($vCardContactUriPropertyValue) && !empty($vCardContactUriPropertyValue)) { - $jsContactContactUriEntry = new Resource(); - $jsContactContactUriEntry->setAtType("Resource"); - $jsContactContactUriEntry->setType("uri"); - $jsContactContactUriEntry->setLabel("contact-uri"); - $jsContactContactUriEntry->setResource($vCardContactUriPropertyValue); + /** + * Writes the ContactCard language to the vCard LANGUAGE property. + * + * @param ContactCard $card + */ + public function setLanguage(ContactCard $card) + { + $language = $card->getLanguage(); + if (is_string($language) && $language !== '') { + $this->addSingleProperty('LANGUAGE', $language); + } + } - if (isset($vCardContactUriProperty['PREF']) && !empty($vCardContactUriProperty['PREF'])) { - $jsContactContactUriEntry->setPref($vCardContactUriProperty['PREF']); - } + /** + * Reads the vCard LANGUAGE and stores it on the ContactCard. + * + * @param ContactCard $card + */ + public function getLanguage(ContactCard $card) + { + $value = $this->getSinglePropertyValue('LANGUAGE'); + if ($value !== null) { + $card->setLanguage($value); + } + } - if (isset($vCardContactUriProperty['TYPE']) && !empty($vCardContactUriProperty['TYPE'])) { - $jsContactContactUriContexts = []; - - foreach ($vCardContactUriProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactContactUriContexts['private'] = true; - break; - - case 'work': - $jsContactContactUriContexts['work'] = true; - break; - - case 'other': - $jsContactContactUriContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property CONTACT-URI: " . $paramValue - ); - break; - } - - $jsContactContactUriEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactContactUriContexts) - ? $jsContactContactUriContexts - : null - ); - } - } + /** + * Writes N property with JSCOMPS parameter reconstructed from ordered components. + * + * @param Name $name + * @param array $components + */ + protected function setNameWithJscomps($name, $components) + { + $kindToPosition = Util::getNameKindToPositionMap(); + $defaultSep = ''; - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the CONTACT-URI property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardContactUriPropertyValue)] = $jsContactContactUriEntry; - } - } + if (method_exists($name, 'getDefaultSeparator')) { + $sep = $name->getDefaultSeparator(); + if ($sep !== null && $sep !== '') { + $defaultSep = $sep; } } - // ORG-DIRECTORY property mapping - if (in_array("ORG-DIRECTORY", $this->vCardChildren)) { - $vCardOrgDirectoryProperties = $this->vCard->__get("ORG-DIRECTORY"); + list($parts, $jscompsValue) = Util::buildJscompsData( + $components, + $kindToPosition, + $defaultSep, + 8 + ); - foreach ($vCardOrgDirectoryProperties as $vCardOrgDirectoryProperty) { - if (isset($vCardOrgDirectoryProperty)) { - $vCardOrgDirectoryPropertyValue = $vCardOrgDirectoryProperty->getValue(); + $params = array('JSCOMPS' => $jscompsValue); + $this->addSingleProperty('N', $parts, $params); + } - if (isset($vCardOrgDirectoryPropertyValue) && !empty($vCardOrgDirectoryPropertyValue)) { - $jsContactOrgDirectoryEntry = new Resource(); - $jsContactOrgDirectoryEntry->setAtType("Resource"); - $jsContactOrgDirectoryEntry->setType("uri"); - $jsContactOrgDirectoryEntry->setLabel("org-directory"); - $jsContactOrgDirectoryEntry->setResource($vCardOrgDirectoryPropertyValue); + /** + * Writes the vCard N property from the name components on the ContactCard. + * + * @param ContactCard $card + */ + public function setName(ContactCard $card) + { + $name = $card->getName(); + if (!($name instanceof Name)) { + return; + } - if (isset($vCardOrgDirectoryProperty['PREF']) && !empty($vCardOrgDirectoryProperty['PREF'])) { - $jsContactOrgDirectoryEntry->setPref($vCardOrgDirectoryProperty['PREF']); - } + $components = $name->getComponents(); + if (!is_array($components) || empty($components)) { + return; + } - if (isset($vCardOrgDirectoryProperty['TYPE']) && !empty($vCardOrgDirectoryProperty['TYPE'])) { - $jsContactOrgDirectoryContexts = []; - - foreach ($vCardOrgDirectoryProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactOrgDirectoryContexts['private'] = true; - break; - - case 'work': - $jsContactOrgDirectoryContexts['work'] = true; - break; - - case 'other': - $jsContactOrgDirectoryContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property ORG-DIRECTORY: " . $paramValue - ); - break; - } - - $jsContactOrgDirectoryEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactOrgDirectoryContexts) - ? $jsContactOrgDirectoryContexts - : null - ); - } - } + $isOrdered = method_exists($name, 'getIsOrdered') ? $name->getIsOrdered() : false; - // If the INDEX parameter is set for ORG-DIRECTORY, then use it as the key for - // the "online" entry representing the corresponding org-directory - // If it's not set, then use a MD5 hash of the org-directory's value - if (isset($vCardOrgDirectoryProperty['INDEX']) && !empty($vCardOrgDirectoryProperty['INDEX'])) { - $jsContactOnlineProperty["ORG-DIRECTORY-" . $vCardOrgDirectoryProperty['INDEX']] - = $jsContactOrgDirectoryEntry; - } else { - $jsContactOnlineProperty[md5($vCardOrgDirectoryPropertyValue)] - = $jsContactOrgDirectoryEntry; - } - } - } - } + if ($isOrdered) { + $this->setNameWithJscomps($name, $components); + return; } - // SOUND property mapping - if (in_array("SOUND", $this->vCardChildren)) { - $vCardSoundProperties = $this->vCard->SOUND; - - foreach ($vCardSoundProperties as $vCardSoundProperty) { - if (isset($vCardSoundProperty)) { - $vCardSoundPropertyValue = $vCardSoundProperty->getValue(); - - if (isset($vCardSoundPropertyValue) && !empty($vCardSoundPropertyValue)) { - $jsContactSoundEntry = new Resource(); - $jsContactSoundEntry->setAtType("Resource"); - $jsContactSoundEntry->setType("uri"); - $jsContactSoundEntry->setLabel("sound"); - $jsContactSoundEntry->setResource($vCardSoundPropertyValue); - - if (isset($vCardSoundProperty['PREF']) && !empty($vCardSoundProperty['PREF'])) { - $jsContactSoundEntry->setPref($vCardSoundProperty['PREF']); - } - - if (isset($vCardSoundProperty['TYPE']) && !empty($vCardSoundProperty['TYPE'])) { - $jsContactSoundContexts = []; - - foreach ($vCardSoundProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactSoundContexts['private'] = true; - break; - - case 'work': - $jsContactSoundContexts['work'] = true; - break; - - case 'other': - $jsContactSoundContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property SOUND: " . $paramValue - ); - break; - } - - $jsContactSoundEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactSoundContexts) - ? $jsContactSoundContexts - : null - ); - } - } + $kindMap = array( + 'surname' => null, + 'given' => null, + 'given2' => null, + 'title' => null, + 'credential' => null + ); - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the SOUND property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardSoundPropertyValue)] = $jsContactSoundEntry; - } - } + foreach ($components as $component) { + $kind = $component->getKind(); + if (array_key_exists($kind, $kindMap)) { + $kindMap[$kind] = $component->getValue(); } } - // URL property mapping - if (in_array("URL", $this->vCardChildren)) { - $vCardUrlProperties = $this->vCard->URL; - - foreach ($vCardUrlProperties as $vCardUrlProperty) { - if (isset($vCardUrlProperty)) { - $vCardUrlPropertyValue = $vCardUrlProperty->getValue(); - - if (isset($vCardUrlPropertyValue) && !empty($vCardUrlPropertyValue)) { - $jsContactUrlEntry = new Resource(); - $jsContactUrlEntry->setAtType("Resource"); - $jsContactUrlEntry->setType("uri"); - $jsContactUrlEntry->setLabel("url"); - $jsContactUrlEntry->setResource($vCardUrlPropertyValue); - - if (isset($vCardUrlProperty['PREF']) && !empty($vCardUrlProperty['PREF'])) { - $jsContactUrlEntry->setPref($vCardUrlProperty['PREF']); - } + $this->setN( + $kindMap['surname'], + $kindMap['given'], + $kindMap['given2'], + $kindMap['title'], + $kindMap['credential'] + ); + } - if (isset($vCardUrlProperty['TYPE']) && !empty($vCardUrlProperty['TYPE'])) { - $jsContactUrlContexts = []; - - foreach ($vCardUrlProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactUrlContexts['private'] = true; - break; - - case 'work': - $jsContactUrlContexts['work'] = true; - break; - - case 'other': - $jsContactUrlContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property URL: " . $paramValue - ); - break; - } - - $jsContactUrlEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactUrlContexts) - ? $jsContactUrlContexts - : null - ); - } - } + /** + * Writes the vCard FN from name.full on the ContactCard. + * Falls back to joining given, middle, and surname if name.full is empty. + * + * @param ContactCard $card + */ + public function setFn(ContactCard $card) + { + $name = $card->getName(); + $full = ($name instanceof Name) ? $name->getFull() : null; - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the URL property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardUrlPropertyValue)] = $jsContactUrlEntry; - } - } - } + if ($full === null || $full === '') { + $full = $this->buildFullNameFromComponents($name); } - // KEY property mapping - if (in_array("KEY", $this->vCardChildren)) { - $vCardKeyProperties = $this->vCard->KEY; - - foreach ($vCardKeyProperties as $vCardKeyProperty) { - if (isset($vCardKeyProperty)) { - $vCardKeyPropertyValue = $vCardKeyProperty->getValue(); - - if (isset($vCardKeyPropertyValue) && !empty($vCardKeyPropertyValue)) { - $jsContactKeyEntry = new Resource(); - $jsContactKeyEntry->setAtType("Resource"); - $jsContactKeyEntry->setType("uri"); - $jsContactKeyEntry->setLabel("key"); - $jsContactKeyEntry->setResource($vCardKeyPropertyValue); + if ($full !== null && $full !== '') { + $this->setDisplayname($full); + } + } - if (isset($vCardKeyProperty['PREF']) && !empty($vCardKeyProperty['PREF'])) { - $jsContactKeyEntry->setPref($vCardKeyProperty['PREF']); - } + /** + * Build a full name string from name components. + * + * @param Name|null $name + * @return string|null + */ + protected function buildFullNameFromComponents($name) + { + if (!($name instanceof Name)) { + return null; + } - if (isset($vCardKeyProperty['TYPE']) && !empty($vCardKeyProperty['TYPE'])) { - $jsContactKeyContexts = []; - - foreach ($vCardKeyProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactKeyContexts['private'] = true; - break; - - case 'work': - $jsContactKeyContexts['work'] = true; - break; - - case 'other': - $jsContactKeyContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property KEY: " . $paramValue - ); - break; - } - - $jsContactKeyEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactKeyContexts) - ? $jsContactKeyContexts - : null - ); - } - } + $components = $name->getComponents(); + if (!is_array($components)) { + return null; + } - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the KEY property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardKeyPropertyValue)] = $jsContactKeyEntry; - } - } + $parts = array(); + foreach ($components as $component) { + $kind = $component->getKind(); + $value = $component->getValue(); + if (($kind === 'given' || $kind === 'given2' || $kind === 'surname') && $value !== null && $value !== '') { + $parts[] = $value; } } - // FBURL property mapping - if (in_array("FBURL", $this->vCardChildren)) { - $vCardFbUrlProperties = $this->vCard->FBURL; - - foreach ($vCardFbUrlProperties as $vCardFbUrlProperty) { - if (isset($vCardFbUrlProperty)) { - $vCardFbUrlPropertyValue = $vCardFbUrlProperty->getValue(); - - if (isset($vCardFbUrlPropertyValue) && !empty($vCardFbUrlPropertyValue)) { - $jsContactFbUrlEntry = new Resource(); - $jsContactFbUrlEntry->setAtType("Resource"); - $jsContactFbUrlEntry->setType("uri"); - $jsContactFbUrlEntry->setLabel("fburl"); - $jsContactFbUrlEntry->setResource($vCardFbUrlPropertyValue); - - if (isset($vCardFbUrlProperty['PREF']) && !empty($vCardFbUrlProperty['PREF'])) { - $jsContactFbUrlEntry->setPref($vCardFbUrlProperty['PREF']); - } + return empty($parts) ? null : implode(' ', $parts); + } - if (isset($vCardFbUrlProperty['TYPE']) && !empty($vCardFbUrlProperty['TYPE'])) { - $jsContactFbUrlContexts = []; - - foreach ($vCardFbUrlProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactFbUrlContexts['private'] = true; - break; - - case 'work': - $jsContactFbUrlContexts['work'] = true; - break; - - case 'other': - $jsContactFbUrlContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property FBURL: " . $paramValue - ); - break; - } - - $jsContactFbUrlEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactFbUrlContexts) - ? $jsContactFbUrlContexts - : null - ); - } - } + /** + * This function maps the vCard "N" property to the JSContact "name" property + * + * @param ContactCard $card + */ + public function getName(ContactCard $card) + { + $n = $this->vCard->N; + $name = new Name(); - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the FBURL property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardFbUrlPropertyValue)] = $jsContactFbUrlEntry; - } - } - } + $fn = $this->getDisplayname(); + if ($fn !== null && $fn !== '') { + $name->setFull($fn); } - // CALADRURI property mapping - if (in_array("CALADRURI", $this->vCardChildren)) { - $vCardCalAdrUriProperties = $this->vCard->CALADRURI; - - foreach ($vCardCalAdrUriProperties as $vCardCalAdrUriProperty) { - if (isset($vCardCalAdrUriProperty)) { - $vCardCalAdrUriPropertyValue = $vCardCalAdrUriProperty->getValue(); + // Check if JSCOMPS is present - if so, parse it to get the correct order + $hasJscomps = AdapterUtil::isSetAndNotNull($n) && isset($n['JSCOMPS']); - if (isset($vCardCalAdrUriPropertyValue) && !empty($vCardCalAdrUriPropertyValue)) { - $jsContactCalAdrUriEntry = new Resource(); - $jsContactCalAdrUriEntry->setAtType("Resource"); - $jsContactCalAdrUriEntry->setType("uri"); - $jsContactCalAdrUriEntry->setLabel("caladruri"); - $jsContactCalAdrUriEntry->setResource($vCardCalAdrUriPropertyValue); + if ($hasJscomps) { + $jscompsValue = (string) $n['JSCOMPS']; + $parts = $n->getParts(); + $positionToKind = Util::getNamePositionToKindMap(); - if (isset($vCardCalAdrUriProperty['PREF']) && !empty($vCardCalAdrUriProperty['PREF'])) { - $jsContactCalAdrUriEntry->setPref($vCardCalAdrUriProperty['PREF']); - } + $components = Util::parseJscompsData( + $jscompsValue, + $parts, + $positionToKind, + 'OpenXPort\\Jmap\\JSContact\\NameComponent' + ); - if (isset($vCardCalAdrUriProperty['TYPE']) && !empty($vCardCalAdrUriProperty['TYPE'])) { - $jsContactCalAdrUriContexts = []; - - foreach ($vCardCalAdrUriProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactCalAdrUriContexts['private'] = true; - break; - - case 'work': - $jsContactCalAdrUriContexts['work'] = true; - break; - - case 'other': - $jsContactCalAdrUriContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property CALADRURI: " . $paramValue - ); - break; - } - - $jsContactCalAdrUriEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactCalAdrUriContexts) - ? $jsContactCalAdrUriContexts - : null - ); - } - } + if (!empty($components)) { + $name->setComponents($components); + $name->setIsOrdered(true); - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the CALADRURI property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardCalAdrUriPropertyValue)] = $jsContactCalAdrUriEntry; - } + $defaultSep = Util::getDefaultSeparatorFromJscomps($jscompsValue); + if ($defaultSep !== null) { + $name->setDefaultSeparator($defaultSep); } } - } - - // CALURI property mapping - if (in_array("CALURI", $this->vCardChildren)) { - $vCardCalUriProperties = $this->vCard->CALURI; - - foreach ($vCardCalUriProperties as $vCardCalUriProperty) { - if (isset($vCardCalUriProperty)) { - $vCardCalUriPropertyValue = $vCardCalUriProperty->getValue(); - - if (isset($vCardCalUriPropertyValue) && !empty($vCardCalUriPropertyValue)) { - $jsContactCalUriEntry = new Resource(); - $jsContactCalUriEntry->setAtType("Resource"); - $jsContactCalUriEntry->setType("uri"); - $jsContactCalUriEntry->setLabel("caluri"); - $jsContactCalUriEntry->setResource($vCardCalUriPropertyValue); - - if (isset($vCardCalUriProperty['PREF']) && !empty($vCardCalUriProperty['PREF'])) { - $jsContactCalUriEntry->setPref($vCardCalUriProperty['PREF']); - } - - if (isset($vCardCalUriProperty['TYPE']) && !empty($vCardCalUriProperty['TYPE'])) { - $jsContactCalUriContexts = []; - - foreach ($vCardCalUriProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactCalUriContexts['private'] = true; - break; - - case 'work': - $jsContactCalUriContexts['work'] = true; - break; - - case 'other': - $jsContactCalUriContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property CALURI: " . $paramValue - ); - break; - } - - $jsContactCalUriEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactCalUriContexts) - ? $jsContactCalUriContexts - : null - ); - } - } + } else { + $componentMap = array( + 'title' => $this->getPrefix(), + 'given' => $this->getFirstName(), + 'given2' => $this->getMiddlename(), + 'surname' => $this->getLastName(), + 'credential' => $this->getSuffix() + ); - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the CALURI property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardCalUriPropertyValue)] = $jsContactCalUriEntry; - } + $components = array(); + foreach ($componentMap as $kind => $value) { + if ($value !== null && $value !== '') { + $c = new NameComponent(); + $c->setKind($kind); + $c->setValue($value); + $components[] = $c; } } + + if (!empty($components)) { + $name->setComponents($components); + $name->setIsOrdered(false); + } } - return $jsContactOnlineProperty; + $card->setName($name); } /** - * This function maps all JSContact "online" entries that correspond to the vCard SOURCE property to it + * This function maps the JSContact "nicknames" property to the vCard NICKNAME property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setSource($jsContactOnlineMap) + public function setNickname(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $nicks = $card->getNicknames(); + if (!is_array($nicks) || empty($nicks)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectMediaType = $resourceObject->mediaType; - $resourceObjectPref = $resourceObject->pref; - $vCardSourceParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "source") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectMediaType) && !empty($resourceObjectMediaType)) { - $vCardSourceParams['mediatype'] = $resourceObjectMediaType; - } - - if (isset($resourceObjectPref)) { - $vCardSourceParams['pref'] = $resourceObjectPref; - } + foreach ($nicks as $id => $nick) { + if (!($nick instanceof Nickname)) { + continue; + } - $this->vCard->add("SOURCE", $resourceObjectResource, $vCardSourceParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard SOURCE property"); - } + $name = $nick->getName(); + if (is_string($name) && $name !== '') { + $params = array(); + $params = Util::addPropIdParam($params, $id); + $params = $this->restoreVcardParams($card, 'NICKNAME', $id, $params); + $this->vCard->add('NICKNAME', $name, $params); } } } /** - * This function maps all JSContact "online" entries that correspond to the vCard IMPP property to it + * This function maps the vCard "NICKNAME" property to the JSContact "nicknames" property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects - * @deprecated + * @param ContactCard $card */ - public function setImpp($jsContactOnlineMap) + public function getNickname(ContactCard $card) { - trigger_error( - "Called method " . __METHOD__ . " uses outdated property, use onlineServices instead.", - E_USER_DEPRECATED - ); - - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $vNicknames = $this->vCard->NICKNAME; + if (!AdapterUtil::isSetAndNotNull($vNicknames) || empty($vNicknames)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardImppParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "XMPP") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardImppParams['type'] = 'home'; - break; - - case 'work': - $vCardImppParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the IMPP vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardImppParams['type'] = 'other'; - } + $map = array(); + $i = 1; - if (isset($resourceObjectPref)) { - $vCardImppParams['pref'] = $resourceObjectPref; - } + foreach ($vNicknames as $prop) { + $nickname = trim((string) $prop); + if ($nickname === '') { + continue; + } - $this->vCard->add("IMPP", $resourceObjectResource, $vCardImppParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard IMPP property"); + $nickObj = new Nickname(); + $nickObj->setName($nickname); + + $key = Util::getPropId($prop); + if ($key === null) { + $key = md5($nickname); + if (isset($map[$key])) { + $key = 'n' . $i++; } } + + $this->preserveVcardParams($card, 'NICKNAME', $key, $prop); + $map[$key] = $nickObj; + } + + if (!empty($map)) { + $card->setNicknames($map); } } /** - * This function maps all JSContact onlineServices entries that correspond to the vCard IMPP property to it + * Writes the grammatical gender to the vCard GRAMGENDER property in uppercase. * - * @param array|null $jsContactOnlineMap - * The "onlineServices" JSContact property as a map of IDs to OnlineService objects + * @param ContactCard $card */ - public function setImppFromServices($jsContactOnlineMap) + public function setGramGender(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $speakToAs = $card->getSpeakToAs(); + if (!is_object($speakToAs)) { return; } - foreach ($jsContactOnlineMap as $id => $onlineObject) { - if (isset($onlineObject) && !empty($onlineObject) && $onlineObject->type == "impp") { - $onlineObjectLabel = $onlineObject->label; - $onlineObjectUser = $onlineObject->user; - $onlineObjectContexts = $onlineObject->contexts; - $onlineObjectPref = $onlineObject->pref; - - $vCardImppParams = []; - - if (isset($onlineObjectLabel) && !empty($onlineObjectLabel)) { - // TODO use X-AB label and groups for mapping. - $this->logger->error("Mapping labels is not yet supported. Dropping label " . $onlineObjectLabel); - } - $vCardImppParams['TYPE'] = JSContactVCardAdapterUtil::convertFromJscontactContexts($onlineContexts); - if (isset($onlineObjectPref)) { - $vCardImppParams['PREF'] = $onlineObjectPref; - } - - $this->vCard->add("IMPP", $onlineObjectUser, $vCardImppParams); - } + $gender = $speakToAs->getGrammaticalGender(); + if ($gender !== null && $gender !== '') { + $this->addSingleProperty('GRAMGENDER', strtoupper($gender)); } } /** - * This function maps all JSContact onlineServices entries that correspond to the vCard SOCIALPROFILE property to it + * Reads the vCard GRAMGENDER property and stores it on the ContactCard in lowercase. + * Falls back to the legacy GENDER property (vCard v3/v4) if GRAMGENDER is absent, + * mapping M/F/N/O to the closest JSContact grammatical gender value. * - * @param array|null $jsContactOnlineMap - * The "onlineServices" JSContact property as a map of IDs to OnlineService objects + * @param ContactCard $card */ - public function setSocialFromServices($jsContactOnlineMap) + public function getGramGender(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { - return; + $gramGender = $this->vCard->__get('GRAMGENDER'); + if (AdapterUtil::isSetAndNotNull($gramGender)) { + $value = strtolower(trim((string) $gramGender)); + if ($value !== '') { + $this->applyGrammaticalGenderToCard($card, $value); + return; + } } - foreach ($jsContactOnlineMap as $id => $onlineObject) { - if (isset($onlineObject) && !empty($onlineObject)) { - $vCardSocialParams = []; - - if ($onlineObject->type == "username") { - $vCardSocialParams["VALUE"] = "TEXT"; - } elseif (!$onlineObject->type == "uri") { - continue; - } - - $onlineObjectLabel = $onlineObject->label; - $onlineObjectUser = $onlineObject->user; - $onlineObjectService = $onlineObject->service; - $onlineObjectContexts = $onlineObject->contexts; - $onlineObjectPref = $onlineObject->pref; + $gender = $this->vCard->__get('GENDER'); + if (!AdapterUtil::isSetAndNotNull($gender)) { + return; + } - if (isset($onlineObjectLabel) && !empty($onlineObjectLabel)) { - // TODO use X-AB label and groups for mapping. - $this->logger->error("Mapping labels is not yet supported. Dropping label " . $onlineObjectLabel); - } - $vCardSocialParams['TYPE'] = JSContactVCardAdapterUtil::convertFromJscontactContexts($onlineContexts); - if (isset($onlineObjectPref)) { - $vCardSocialParams['PREF'] = $onlineObjectPref; - } - if (isset($onlineObjectService)) { - $vCardSocialParams['SERVICE-TYPE'] = $onlineObjectService; - } + $raw = trim((string) $gender); + $parts = explode(';', $raw, 2); + $sex = trim($parts[0]); - $this->vCard->add("SOCIALPROFILE", $onlineObjectUser, $vCardSocialParams); - } + $mapped = Util::mapGenderToGrammatical($sex); + if ($mapped !== null) { + $this->applyGrammaticalGenderToCard($card, $mapped); } } /** - * This function maps all JSContact "online" entries that correspond to the vCard LOGO property to it + * Sets grammaticalGender on the card's SpeakToAs object. * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card + * @param string $value Lowercase JSContact gender value. */ - public function setLogo($jsContactOnlineMap) + private function applyGrammaticalGenderToCard(ContactCard $card, $value) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { - return; + $speakToAs = $card->getSpeakToAs(); + if (!is_object($speakToAs)) { + $speakToAs = new SpeakToAs(); } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardLogoParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - isset($resourceObjectLabel) && strcmp($resourceObjectLabel, "logo") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardLogoParams['type'] = 'home'; - break; - - case 'work': - $vCardLogoParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the LOGO vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardLogoParams['type'] = 'other'; - } - - if (isset($resourceObjectPref)) { - $vCardLogoParams['pref'] = $resourceObjectPref; - } - - $this->vCard->add("LOGO", $resourceObjectResource, $vCardLogoParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard LOGO property"); - } - } + if ($speakToAs) { + $speakToAs->setGrammaticalGender($value); + $card->setSpeakToAs($speakToAs); } } /** - * This function maps all JSContact "online" entries that correspond to the vCard CONTACT-URI property to it + * Writes each set of pronouns from the ContactCard as a vCard PRONOUNS property. * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setContactUri($jsContactOnlineMap) + public function setPronouns(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $speakToAs = $card->getSpeakToAs(); + if (!is_object($speakToAs)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardContactUriParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "contact-uri") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardContactUriParams['type'] = 'home'; - break; - - case 'work': - $vCardContactUriParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the CONTACT-URI vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardContactUriParams['type'] = 'other'; - } + $pronouns = $speakToAs->getPronouns(); + if (!is_array($pronouns) || empty($pronouns)) { + return; + } - if (isset($resourceObjectPref)) { - $vCardContactUriParams['pref'] = $resourceObjectPref; - } + foreach ($pronouns as $id => $pronounObj) { + if (!is_object($pronounObj)) { + continue; + } - $this->vCard->add("CONTACT-URI", $resourceObjectResource, $vCardContactUriParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard CONTACT-URI property"); + $value = $pronounObj->getPronouns(); + if (!is_string($value) || $value === '') { + continue; + } + + $params = array(); + if (method_exists($pronounObj, 'getPref') && method_exists($pronounObj, 'getContexts')) { + $params = Util::buildContextPrefParams($pronounObj); + } else { + $pref = Util::prefToVcardParam($pronounObj); + if ($pref !== null) { + $params['PREF'] = $pref; } } + $params = Util::addPropIdParam($params, $id); + $params = $this->restoreVcardParams($card, 'PRONOUNS', $id, $params); + + $this->vCard->add('PRONOUNS', $value, $params); } } /** - * This function maps all JSContact "online" entries that correspond to the vCard ORG-DIRECTORY property to it + * Reads all vCard PRONOUNS properties and stores them on the ContactCard. * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setOrgDirectory($jsContactOnlineMap) + public function getPronouns(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $vPronouns = $this->vCard->__get('PRONOUNS'); + if (!AdapterUtil::isSetAndNotNull($vPronouns) || empty($vPronouns)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardOrgDirectoryParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "org-directory") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardOrgDirectoryParams['type'] = 'home'; - break; - - case 'work': - $vCardOrgDirectoryParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the ORG-DIRECTORY vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardOrgDirectoryParams['type'] = 'other'; - } + $map = array(); + $idx = 1; - if (isset($resourceObjectPref)) { - $vCardOrgDirectoryParams['pref'] = $resourceObjectPref; - } + foreach ($vPronouns as $prop) { + $value = trim((string) $prop); + if ($value === '') { + continue; + } - if (isset($id) && !empty($id) && strcmp("ORG-DIRECTORY", substr($id, 0, 13)) === 0) { - $vCardOrgDirectoryParams['index'] = substr($id, 12, 1); - } + $pronounObj = new Pronouns($value); - $this->vCard->add("ORG-DIRECTORY", $resourceObjectResource, $vCardOrgDirectoryParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard ORG-DIRECTORY property"); - } + if (!$pronounObj) { + continue; + } + + $pronounObj->setPronouns($value); + Util::applyCommonContextAndPref($pronounObj, $prop); + + $key = Util::getMapKeyFromPropValue($prop, $value, 'pr', $idx, $map); + $this->preserveVcardParams($card, 'PRONOUNS', $key, $prop); + $map[$key] = $pronounObj; + } + + if (!empty($map)) { + $speakToAs = $card->getSpeakToAs(); + if (!is_object($speakToAs)) { + $speakToAs = new SpeakToAs(); + } + + if ($speakToAs) { + $speakToAs->setPronouns($map); + $card->setSpeakToAs($speakToAs); } } } /** - * This function maps all JSContact "online" entries that correspond to the vCard SOUND property to it + * This function maps the JSContact "organizations" property to the vCard ORG property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setSound($jsContactOnlineMap) + public function setOrganizations(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $orgs = $card->getOrganizations(); + if (!is_array($orgs) || empty($orgs)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardSoundParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "sound") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardSoundParams['type'] = 'home'; - break; - - case 'work': - $vCardSoundParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the SOUND vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardSoundParams['type'] = 'other'; - } + foreach ($orgs as $id => $org) { + if (!($org instanceof Organization)) { + continue; + } - if (isset($resourceObjectPref)) { - $vCardSoundParams['pref'] = $resourceObjectPref; - } + $name = $org->getName(); + $units = array(); - $this->vCard->add("SOUND", $resourceObjectResource, $vCardSoundParams); + $u = $org->getUnits(); + if (is_array($u)) { + foreach ($u as $unitObj) { + if (is_object($unitObj)) { + $unitName = $unitObj->getName(); + if (is_string($unitName) && $unitName !== '') { + $units[] = $unitName; + } + } elseif (is_string($unitObj) && $unitObj !== '') { + $units[] = $unitObj; } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard SOUND property"); } } + + $parts = array_merge(array($name), $units); + + $params = array(); + $types = Util::contextsToVcardTypeParam($org); + if (!empty($types)) { + $params['TYPE'] = $types; + } + + $params = Util::addPropIdParam($params, $id); + $params = $this->restoreVcardParams($card, 'ORG', $id, $params); + $this->vCard->add('ORG', $parts, $params); } } /** - * This function maps all JSContact "online" entries that correspond to the vCard URL property to it + * This function maps the vCard "ORG" property to the JSContact "organizations" property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setUrl($jsContactOnlineMap) + public function getOrganizations(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $vOrgs = $this->vCard->ORG; + if (!AdapterUtil::isSetAndNotNull($vOrgs) || empty($vOrgs)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardUrlParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "url") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardUrlParams['type'] = 'home'; - break; - - case 'work': - $vCardUrlParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the URL vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardUrlParams['type'] = 'other'; - } + $map = array(); + $idx = 1; - if (isset($resourceObjectPref)) { - $vCardUrlParams['pref'] = $resourceObjectPref; - } + foreach ($vOrgs as $vOrg) { + if (!($vOrg instanceof Property)) { + continue; + } + $parts = $vOrg->getParts(); + if (!is_array($parts) || empty($parts)) { + $raw = trim((string) $vOrg); + if ($raw === '') { + continue; + } + $parts = array($raw); + } - $this->vCard->add("URL", $resourceObjectResource, $vCardUrlParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard URL property"); + $org = new Organization(); + $org->setName(isset($parts[0]) ? (string) $parts[0] : ''); + + $units = array(); + for ($i = 1; $i < count($parts); $i++) { + $u = (string) $parts[$i]; + if ($u !== '') { + $units[] = $u; } } + if (!empty($units)) { + $org->setUnits($units); + } + + Util::applyCommonContextAndPref($org, $vOrg); + + $valueForKey = implode(';', $parts); + $key = Util::getMapKeyFromPropValue($vOrg, $valueForKey, 'o', $idx, $map); + $this->preserveVcardParams($card, 'ORG', $key, $vOrg); + $map[$key] = $org; + } + + if (!empty($map)) { + $card->setOrganizations($map); } } /** - * This function maps all JSContact "online" entries that correspond to the vCard KEY property to it + * This function maps the JSContact "titles" property to the vCard TITLE or ROLE property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setKey($jsContactOnlineMap) + public function setTitles(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $titles = $card->getTitles(); + if (!is_array($titles) || empty($titles)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardKeyParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "key") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardKeyParams['type'] = 'home'; - break; - - case 'work': - $vCardKeyParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the KEY vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardKeyParams['type'] = 'other'; - } + foreach ($titles as $id => $titleObj) { + if (!($titleObj instanceof Title)) { + continue; + } - if (isset($resourceObjectPref)) { - $vCardKeyParams['pref'] = $resourceObjectPref; - } + $kind = $titleObj->getKind(); + $name = $titleObj->getName(); - $this->vCard->add("KEY", $resourceObjectResource, $vCardKeyParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard KEY property"); - } + if ($name === null || $name === '') { + continue; + } + + $params = array(); + $params = Util::addPropIdParam($params, $id); + + if ($kind === 'role') { + $params = $this->restoreVcardParams($card, 'ROLE', $id, $params); + $this->vCard->add('ROLE', $name, $params); + } else { + $params = $this->restoreVcardParams($card, 'TITLE', $id, $params); + $this->vCard->add('TITLE', $name, $params); } } } /** - * This function maps all JSContact "online" entries that correspond to the vCard FBURL property to it + * This function maps the vCard "TITLE" and "ROLE" properties to the JSContact "titles" property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setFbUrl($jsContactOnlineMap) + public function getTitles(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { - return; - } - - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardFbUrlParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "fburl") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardFbUrlParams['type'] = 'home'; - break; - - case 'work': - $vCardFbUrlParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the FBURL vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardFbUrlParams['type'] = 'other'; - } + $map = array(); + $idx = 1; - if (isset($resourceObjectPref)) { - $vCardFbUrlParams['pref'] = $resourceObjectPref; - } + $vTitles = $this->vCard->TITLE; + if (AdapterUtil::isSetAndNotNull($vTitles) && !empty($vTitles)) { + foreach ($vTitles as $vTitle) { + $name = trim((string) $vTitle); + if ($name === '') { + continue; + } + $t = new Title(); + $t->setKind('title'); + $t->setName($name); + $key = Util::getMapKeyFromPropValue($vTitle, $name, 't', $idx, $map); + $this->preserveVcardParams($card, 'TITLE', $key, $vTitle); + $map[$key] = $t; + } + } - $this->vCard->add("FBURL", $resourceObjectResource, $vCardFbUrlParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard FBURL property"); + $vRoles = $this->vCard->ROLE; + if (AdapterUtil::isSetAndNotNull($vRoles) && !empty($vRoles)) { + foreach ($vRoles as $vRole) { + $name = trim((string) $vRole); + if ($name === '') { + continue; } + $t = new Title(); + $t->setKind('role'); + $t->setName($name); + $key = Util::getMapKeyFromPropValue($vRole, $name, 't', $idx, $map); + $this->preserveVcardParams($card, 'ROLE', $key, $vRole); + $map[$key] = $t; } } + + if (!empty($map)) { + $card->setTitles($map); + } } /** - * This function maps all JSContact "online" entries that correspond to the vCard CALADRURI property to it + * This function maps the JSContact "notes" property to the vCard NOTE property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setCalAdrUri($jsContactOnlineMap) + public function setNotes(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $notes = $card->getNoteObjects(); + if (!is_array($notes) || empty($notes)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardCalAdrUriParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "caladruri") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardCalAdrUriParams['type'] = 'home'; - break; - - case 'work': - $vCardCalAdrUriParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the CALADRURI vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardCalAdrUriParams['type'] = 'other'; - } + foreach ($notes as $id => $note) { + if (!($note instanceof Note)) { + continue; + } - if (isset($resourceObjectPref)) { - $vCardCalAdrUriParams['pref'] = $resourceObjectPref; - } + $text = $note->getNote(); + if (is_string($text) && $text !== '') { + $params = array(); + + $created = $note->getCreated(); + if ($created !== null) { + $params['CREATED'] = Util::parseDateTimeToVcardTimestamp($created); + } - $this->vCard->add("CALADRURI", $resourceObjectResource, $vCardCalAdrUriParams); + $author = $note->getAuthor(); + if (is_object($author)) { + $authorName = $author->getName(); + if ($authorName !== null) { + $params['AUTHOR-NAME'] = $authorName; + } + $authorUri = $author->getUri(); + if ($authorUri !== null) { + $params['AUTHOR'] = $authorUri; } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard CALADRURI property"); } + $params = Util::addPropIdParam($params, $id); + $params = $this->restoreVcardParams($card, 'NOTE', $id, $params); + $this->vCard->add('NOTE', $text, $params); } } } /** - * This function maps all JSContact "online" entries that correspond to the vCard CALURI property to it + * This function maps the vCard "NOTE" property to the JSContact "notes" property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card */ - public function setCalUri($jsContactOnlineMap) + public function getNotes(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $vNotes = $this->vCard->NOTE; + if (!AdapterUtil::isSetAndNotNull($vNotes) || empty($vNotes)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardCalUriParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "caluri") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardCalUriParams['type'] = 'home'; - break; - - case 'work': - $vCardCalUriParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the CALURI vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // In case that $resourceObjectContexts is null, we set the vCard type to be 'other' - $vCardCalUriParams['type'] = 'other'; - } + $map = array(); + $i = 1; - if (isset($resourceObjectPref)) { - $vCardCalUriParams['pref'] = $resourceObjectPref; - } + foreach ($vNotes as $prop) { + $noteText = trim((string) $prop); + if ($noteText === '') { + continue; + } - $this->vCard->add("CALURI", $resourceObjectResource, $vCardCalUriParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard CALURI property"); + $note = new Note(); + $note->setNote($noteText); + + if (isset($prop['CREATED'])) { + $created = Util::parseTimestampDateTime((string) $prop['CREATED']); + if ($created !== null) { + $note->setCreated($created); } } + + $hasAuthor = false; + $author = null; + + if (isset($prop['AUTHOR-NAME']) || isset($prop['AUTHOR'])) { + $author = new Author(); + $hasAuthor = true; + } + + if ($hasAuthor && $author) { + if (isset($prop['AUTHOR-NAME'])) { + $author->setName((string) $prop['AUTHOR-NAME']); + } + if (isset($prop['AUTHOR'])) { + $author->setUri((string) $prop['AUTHOR']); + } + $note->setAuthor($author); + } + + $key = Util::getMapKeyFromPropValue($prop, $noteText, 'n', $i, $map); + $this->preserveVcardParams($card, 'NOTE', $key, $prop); + + $map[$key] = $note; + } + + if (!empty($map)) { + $card->setNoteObjects($map); } } /** - * This function maps the vCard KIND property to the JSContact "kind" property + * This function maps the JSContact "emails" property to the vCard EMAIL property * - * @return string|null The "kind" JSContact property as a string + * @param ContactCard $card */ - public function getKind() + public function setEmails(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $emails = $card->getEmails(); + if (!is_array($emails) || empty($emails)) { return; } - $jsContactKindProperty = null; - - // KIND property mapping - if (in_array("KIND", $this->vCardChildren)) { - $vCardKindProperty = $this->vCard->KIND; + foreach ($emails as $id => $email) { + if (!($email instanceof EmailAddress)) { + continue; + } - if (isset($vCardKindProperty)) { - $vCardKindPropertyValue = $vCardKindProperty->getValue(); + $addr = $email->getAddress(); + if (!is_string($addr) || trim($addr) === '') { + continue; + } - // Only if the vCard KIND property indeed has a value, we map it to "kind" in JSContact. - // Moreover, skip the KIND value if it's equal to "group", since "group" is only relevant for CardGroup - if (isset($vCardKindPropertyValue) && !empty($vCardKindPropertyValue)) { - if (isset($this->vCard->MEMBER) || strcmp($vCardKindPropertyValue, "group") === 0) { - $this->logger->error( - "vCard MEMBER property is set and/or KIND has value of \"group\" which is not allowed for - vCards not representing a group" - ); - } else { - $jsContactKindProperty = $vCardKindPropertyValue; - } + $params = array(); + if (method_exists($email, 'getPref') && method_exists($email, 'getContexts')) { + $params = Util::buildContextPrefParams($email); + } else { + // Fallback: manually build params + $types = Util::contextsToVcardTypeParam($email); + if (!empty($types)) { + $params['TYPE'] = $types; + } + $pref = Util::prefToVcardParam($email); + if ($pref !== null) { + $params['PREF'] = $pref; } } - } + $params = Util::addPropIdParam($params, $id); - return $jsContactKindProperty; + $this->vCard->add('EMAIL', $addr, $params); + } } /** - * This function maps the JSContact "kind" property to the vCard KIND property + * This function maps the vCard "EMAIL" property to the JSContact "emails" property * - * @param string|null $jsContactKind - * The "kind" JSContact property as a string + * @param ContactCard $card */ - public function setKind($jsContactKind) + public function getEmails(ContactCard $card) { - if (!isset($jsContactKind) || empty($jsContactKind)) { + $vEmails = $this->vCard->EMAIL; + if (!AdapterUtil::isSetAndNotNull($vEmails) || empty($vEmails)) { return; } - $this->vCard->add("KIND", $jsContactKind); + $map = array(); + $i = 1; + + foreach ($vEmails as $prop) { + $value = trim((string) $prop); + if ($value === '') { + continue; + } + + $e = new EmailAddress(); + $e->setAddress($value); + + Util::applyCommonContextAndPref($e, $prop); + + $key = Util::getMapKeyFromPropValue($prop, $value, 'e', $i, $map); + $map[$key] = $e; + } + + if (!empty($map)) { + $card->setEmails($map); + } } /** - * This function maps the vCard FN property to the JSContact "fullName" property + * This function maps the JSContact "phones" property to the vCard TEL property * - * @return string|null The "fullName" JSContact property as a string + * @param ContactCard $card */ - public function getFullName() + public function setPhones($card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $phones = $card->getPhones(); + if (!is_array($phones) || empty($phones)) { return; } - $jsContactFullNameProperty = null; + foreach ($phones as $phone) { + if (!($phone instanceof Phone)) { + continue; + } - // FN property mapping - if (in_array("FN", $this->vCardChildren)) { - $vCardFNProperty = $this->vCard->FN; + $number = $phone->getNumber(); + if (!is_string($number) || trim($number) === '') { + continue; + } - if (isset($vCardFNProperty)) { - $vCardFNPropertyValue = $vCardFNProperty->getValue(); + $label = strtolower((string)$phone->getLabel()); + $roundcubeTypes = array('home2', 'work2', 'homefax', 'workfax'); - // Only if the vCard FN property indeed has a value, we map it to "fullName" in JSContact - if (isset($vCardFNPropertyValue) && !empty($vCardFNPropertyValue)) { - $jsContactFullNameProperty = $vCardFNPropertyValue; - } + $params = array(); + $pref = Util::prefToVcardParam($phone); + if ($pref !== null) { + $params['PREF'] = $pref; + } - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if (isset($vCardFNProperty['ALTID']) && !empty($vCardFNProperty['ALTID'])) { - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered for vCard property FN" - ); + if (in_array($label, $roundcubeTypes, true)) { + $params['TYPE'] = array($label); + } else { + $types = Util::contextsToVcardTypeParam($phone); + $features = $phone->getFeatures(); + if (is_array($features)) { + foreach ($features as $name => $flag) { + if ($flag) { + $types[] = ($name === 'mobile') ? 'cell' : $name; + } + } } - - if (isset($vCardFNProperty['LANGUAGE']) && !empty($vCardFNProperty['LANGUAGE'])) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered for vCard property FN" - ); + if (!empty($types)) { + $params['TYPE'] = array_values(array_unique($types)); } } - } - return $jsContactFullNameProperty; + $this->vCard->add('TEL', $number, $params); + } } /** - * This function maps the JSContact "fullName" property to the vCard FN property + * This function maps the vCard "TEL" property to the JSContact "phones" property * - * @param string|null $jsContactFullName - * The "fullName" JSContact property as a string + * @param ContactCard $card */ - public function setFN($jsContactFullName) + public function getPhones(ContactCard $card) { - if (!isset($jsContactFullName) || empty($jsContactFullName)) { + $vPhones = $this->vCard->TEL; + if (!AdapterUtil::isSetAndNotNull($vPhones) || empty($vPhones)) { return; } - $this->vCard->add("FN", $jsContactFullName); - } + $map = array(); + $i = 1; - /** - * This function maps the vCard "N" property to the JSContact "name" property - * - * @return Name|null The "name" JSContact property as a Name object comprising NameComponents - */ - public function getName() - { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } + foreach ($vPhones as $prop) { + $value = trim((string) $prop); + if ($value === '') { + continue; + } - $jsContactNameProperty = null; + $p = new Phone(); + $p->setNumber($value); - // N property mapping - if (in_array("N", $this->vCardChildren)) { - $vCardNProperty = $this->vCard->N; + $ctx = Util::vCardTypeParamToContexts($prop); + if (!empty($ctx)) { + $p->setContexts($ctx); + } - if (isset($vCardNProperty)) { - $vCardNPropertyValues = $vCardNProperty->getParts(); + $features = array(); + $labels = array(); - // Only if the vCard N property indeed has any values, create - // corresponding entries in the JSContact "name" property - if (isset($vCardNPropertyValues) && !empty($vCardNPropertyValues)) { - $nameComponents = array(); + if (isset($prop['TYPE'])) { + $types = $prop['TYPE']->getParts(); + if (is_array($types)) { + foreach ($types as $t) { + $t = strtolower(trim((string) $t)); + if ($t === '' || $t === 'home' || $t === 'work') { + continue; + } - $prefixNameComponent = null; - $givenNameComponent = null; - $surnameNameComponent = null; - $additionalNameComponent = null; - $suffixNameComponent = null; + if ($t === 'cell') { + $features['mobile'] = true; + } elseif ( + in_array($t, array('voice', 'fax', + 'pager', 'text', 'textphone', 'video', 'main-number'), true) + ) { + $features[$t] = true; + } else { + $labels[] = $t; + } + } + } + } - // Note: it is important to order the name components in a way that they can - // logically form an entire name if concatenated. That's why they're appended - // to the $nameComponents array below in this order. + if (!empty($features)) { + $p->setFeatures($features); + } - // Create a NameComponent for prefix if vCard prefix exists - if (isset($vCardNPropertyValues[3]) && !empty($vCardNPropertyValues[3])) { - $prefixNameComponent = new NameComponent(); - $prefixNameComponent->setAtType("NameComponent"); - $prefixNameComponent->setValue($vCardNPropertyValues[3]); - $prefixNameComponent->setType("prefix"); + if (!empty($labels)) { + $p->setLabel(implode(', ', $labels)); + } - $nameComponents[] = $prefixNameComponent; - } + $pref = Util::vCardPrefParamToInt($prop); + if ($pref !== null) { + $p->setPref($pref); + } - // Create a NameComponent for given name if vCard given name exists - if (isset($vCardNPropertyValues[1]) && !empty($vCardNPropertyValues[1])) { - $givenNameComponent = new NameComponent(); - $givenNameComponent->setAtType("NameComponent"); - $givenNameComponent->setValue($vCardNPropertyValues[1]); - $givenNameComponent->setType("given"); + $key = Util::getMapKeyFromPropValue($prop, $value, 'p', $i, $map); + $map[$key] = $p; + } - $nameComponents[] = $givenNameComponent; - } + if (!empty($map)) { + $card->setPhones($map); + } + } - // Create a NameComponent for surname if vCard surname exists - if (isset($vCardNPropertyValues[0]) && !empty($vCardNPropertyValues[0])) { - $surnameNameComponent = new NameComponent(); - $surnameNameComponent->setAtType("NameComponent"); - $surnameNameComponent->setValue($vCardNPropertyValues[0]); - $surnameNameComponent->setType("surname"); + /** + * This function maps the JSContact "onlineServices" property to the vCard IMPP, SOCIALPROFILE, and URL properties + * + * @param ContactCard $card + */ + public function setOnlineServices(ContactCard $card) + { + $online = $card->getOnlineServices(); + if (!is_array($online) || empty($online)) { + return; + } - $nameComponents[] = $surnameNameComponent; - } + foreach ($online as $os) { + if (!($os instanceof OnlineService)) { + continue; + } - // Create a NameComponent for additional name if vCard additional name exists - if (isset($vCardNPropertyValues[2]) && !empty($vCardNPropertyValues[2])) { - $additionalNameComponent = new NameComponent(); - $additionalNameComponent->setAtType("NameComponent"); - $additionalNameComponent->setValue($vCardNPropertyValues[2]); - $additionalNameComponent->setType("additional"); + $service = $os->getService(); + $user = $os->getUser(); - $nameComponents[] = $additionalNameComponent; - } + $value = Util::getOnlineServiceExportValue($os); + if ($value === null || $value === '') { + continue; + } - // Create a NameComponent for suffix if vCard suffix exists - if (isset($vCardNPropertyValues[4]) && !empty($vCardNPropertyValues[4])) { - $suffixNameComponent = new NameComponent(); - $suffixNameComponent->setAtType("NameComponent"); - $suffixNameComponent->setValue($vCardNPropertyValues[4]); - $suffixNameComponent->setType("suffix"); + $params = array(); - $nameComponents[] = $suffixNameComponent; - } + if (is_string($service) && $service !== '') { + $params['SERVICE-TYPE'] = $service; + } + if ($user !== null && $user !== '') { + $params['USERNAME'] = $user; + } - $jsContactNameProperty = new Name(); - $jsContactNameProperty->setAtType("Name"); - $jsContactNameProperty->setComponents($nameComponents); - } + $types = Util::contextsToVcardTypeParam($os); + if (!empty($types)) { + $params['TYPE'] = $types; } - } - return $jsContactNameProperty; + $pref = Util::prefToVcardParam($os); + if ($pref !== null) { + $params['PREF'] = $pref; + } + + $propName = Util::determineOnlinePropertyType( + $os->getUri(), + $os->getService() + ); + + $this->vCard->add($propName, $value, $params); + } } /** - * This function maps the JSContact "name" property to the vCard N property + * This function maps the vCard "IMPP", "SOCIALPROFILE" and "URL" properties + * to the JSContact "onlineServices" property * - * @param Name|null $jsContactName - * The "name" JSContact property as a Name object + * @param ContactCard $card */ - public function setN($jsContactName) + public function getOnlineServices(ContactCard $card) { - if (!isset($jsContactName) || empty($jsContactName)) { - return; + $map = array(); + $idx = 1; + + $props = array('IMPP', 'SOCIALPROFILE', 'URL'); + + foreach ($props as $propName) { + $items = $propName === 'SOCIALPROFILE' + ? $this->vCard->__get('SOCIALPROFILE') + : $this->vCard->{$propName}; + + if (!AdapterUtil::isSetAndNotNull($items) || empty($items)) { + continue; + } + + foreach ($items as $prop) { + $value = trim((string) $prop); + if ($value === '') { + continue; + } + + $os = new OnlineService(); + + if (isset($prop['SERVICE-TYPE'])) { + $os->setService((string) $prop['SERVICE-TYPE']); + } + + $serviceType = isset($prop['SERVICE-TYPE']) + ? strtolower(trim((string) $prop['SERVICE-TYPE'])) + : null; + + if (isset($prop['USERNAME'])) { + $os->setUser((string) $prop['USERNAME']); + } else { + Util::assignOnlineValue($os, $propName, $value, $serviceType); + } + + Util::applyCommonContextAndPref($os, $prop); + + $key = Util::getMapKeyFromPropValue($prop, $value, 'os', $idx, $map); + $map[$key] = $os; + } } - $vCardNProperty = $this->vCard->createProperty( - "N", - JSContactVCardAdapterUtil::convertFromNameToN($jsContactName) - ); - $this->vCard->add($vCardNProperty); + if (!empty($map)) { + $card->setOnlineServices($map); + } } /** - * This function maps the vCard "NICKNAME" property to the JSContact "nickNames" property + * This function maps the vCard "LANG" property to the JSContact "preferredLanguages" property * - * @return array|null The "nickNames" JSContact property as an array of strings (containing nicknames) + * @param ContactCard $card */ - public function getNickNames() + public function getPreferredLanguages(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $vLangs = $this->vCard->LANG; + if (!AdapterUtil::isSetAndNotNull($vLangs) || empty($vLangs)) { return; } - $jsContactNickNamesProperty = null; + $map = array(); + $idx = 1; - // NICKNAME property mapping - if (in_array("NICKNAME", $this->vCardChildren)) { - $vCardNicknameProperties = $this->vCard->NICKNAME; + foreach ($vLangs as $prop) { + $tag = trim((string) $prop); + if ($tag === '') { + continue; + } - foreach ($vCardNicknameProperties as $vCardNicknameProperty) { - if (isset($vCardNicknameProperty)) { - $vCardNicknamePropertyValue = $vCardNicknameProperty->getValue(); + $lp = new LanguagePref(); + $lp->setLanguage($tag); - // Only if the vCard NICKNAME property indeed has a value, we add it as an element of - // "nickNames" in JSContact - if (isset($vCardNicknamePropertyValue) && !empty($vCardNicknamePropertyValue)) { - $jsContactNickNamesProperty[] = $vCardNicknamePropertyValue; - } - } - } + Util::applyCommonContextAndPref($lp, $prop); + + $key = Util::getMapKeyFromPropValue($prop, $tag, 'lp', $idx, $map); + $map[$key] = $lp; } - return $jsContactNickNamesProperty; + if (!empty($map)) { + $card->setPreferredLanguages($map); + } } /** - * This function maps the JSContact "nickNames" property to the vCard NICKNAME property + * This function maps the JSContact "preferredLanguages" property to the vCard LANG property * - * @param array|null $jsContactNickNames - * The "nickNames" JSContact property as an array of strings + * @param ContactCard $card */ - public function setNickname($jsContactNickNames) + public function setPreferredLanguages(ContactCard $card) { - if (!isset($jsContactNickNames) || empty($jsContactNickNames)) { + $langs = $card->getPreferredLanguages(); + if (!is_array($langs) || empty($langs)) { return; } - foreach ($jsContactNickNames as $jsContactNickName) { - if (isset($jsContactNickName) && !empty($jsContactNickName)) { - $this->vCard->add("NICKNAME", $jsContactNickName); + foreach ($langs as $id => $lp) { + if (!($lp instanceof LanguagePref)) { + continue; + } + + $tag = trim((string) $lp->getLanguage()); + if ($tag === '') { + continue; + } + + $params = array(); + if (method_exists($lp, 'getPref') && method_exists($lp, 'getContexts')) { + $params = Util::buildContextPrefParams($lp); + } else { + // Fallback: manually build params + $ctx = $lp->getContexts(); + if (is_array($ctx)) { + $types = array(); + if (!empty($ctx['private'])) { + $types[] = 'home'; + } + if (!empty($ctx['work'])) { + $types[] = 'work'; + } + if (!empty($types)) { + $params['TYPE'] = $types; + } + } + $pref = $lp->getPref(); + if (is_int($pref) && $pref > 0) { + $params['PREF'] = (string) $pref; + } } + $params = Util::addPropIdParam($params, $id); + + $this->vCard->add('LANG', $tag, $params); } } /** - * This function maps the vCard "PHOTO" property to the JSContact "photos" property + * Creates a JSContact Media object from a URI, kind, and optional vCard property for extra parameters. * - * @return array|null The "photos" JSContact property as a map of IDs to File objects + * @param string $uri + * @param string $kind One of: photo, logo, sound. + * @param mixed|null $prop + * @return object|null */ - public function getPhotos() + protected function makeMediaObject($uri, $kind, $prop = null) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactPhotosProperty = null; - - // PHOTO property mapping - if (in_array("PHOTO", $this->vCardChildren)) { - $vCardPhotoProperties = $this->vCard->PHOTO; + return Util::createMediaObject($uri, $kind, $prop); + } - foreach ($vCardPhotoProperties as $vCardPhotoProperty) { - if (isset($vCardPhotoProperty)) { - $vCardPhotoPropertyValue = $vCardPhotoProperty->getRawMimeDirValue(); + /** + * This function maps the vCard "PHOTO", "LOGO" and "SOUND" properties to the JSContact "media" property + * + * @param ContactCard $card + */ + public function getMedia(ContactCard $card) + { + $map = array(); + $idx = 1; - // Only if the vCard PHOTO property indeed has a value, we add it as "href" into a File object - // which in turn is an element of "photos" in JSContact - if (isset($vCardPhotoPropertyValue) && !empty($vCardPhotoPropertyValue)) { - $jsContactPhoto = new File(); - $jsContactPhoto->setAtType("File"); - $jsContactPhoto->setHref($vCardPhotoPropertyValue); + $sources = array( + 'PHOTO' => 'photo', + 'LOGO' => 'logo', + 'SOUND' => 'sound', + ); - if (isset($vCardPhotoProperty['PREF']) && !empty($vCardPhotoProperty['PREF'])) { - $jsContactPhoto->setPref($vCardPhotoProperty['PREF']); - } + foreach ($sources as $propName => $kind) { + $items = $this->vCard->{$propName}; + if (!AdapterUtil::isSetAndNotNull($items) || empty($items)) { + continue; + } - if (isset($vCardPhotoProperty['MEDIATYPE']) && !empty($vCardPhotoProperty['MEDIATYPE'])) { - $jsContactPhoto->setMediaType($vCardPhotoProperty['MEDIATYPE']); - } + foreach ($items as $prop) { + $uri = trim((string) $prop); + if ($uri === '') { + continue; + } - // Since "photos" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the PHOTO property's value to create the key of the entry in "photos" - $jsContactPhotosProperty[md5($vCardPhotoPropertyValue)] = $jsContactPhoto; - } + $media = $this->makeMediaObject($uri, $kind, $prop); + if ($media !== null) { + $key = Util::getMapKeyFromPropValue($prop, $uri, 'm', $idx, $map); + $map[$key] = $media; } } } - return $jsContactPhotosProperty; + if (!empty($map)) { + $card->setMedia($map); + } } /** - * This function maps the JSContact "photos" property to the vCard PHOTO property + * This function maps the JSContact "media" property to the vCard PHOTO, LOGO, or SOUND properties * - * @param array|null $jsContactPhotos - * The "photos" JSContact property as a map of strings to File objects + * @param ContactCard $card */ - public function setPhoto($jsContactPhotos) + public function setMedia(ContactCard $card) { - if (!isset($jsContactPhotos) || empty($jsContactPhotos)) { + $mediaMap = $card->getMedia(); + if (!is_array($mediaMap) || empty($mediaMap)) { return; } - foreach ($jsContactPhotos as $id => $jsContactPhoto) { - if (isset($jsContactPhoto) && !empty($jsContactPhoto)) { - $jsContactPhotoValue = $jsContactPhoto->href; - $jsContactPhotoPref = $jsContactPhoto->pref; - $jsContactPhotoMediaType = $jsContactPhoto->mediaType; + $kindToVcard = array( + 'photo' => 'PHOTO', + 'logo' => 'LOGO', + 'sound' => 'SOUND', + ); - $vCardPhotoParams = []; + foreach ($mediaMap as $id => $media) { + if (!is_object($media)) { + continue; + } - if (isset($jsContactPhotoPref)) { - $vCardPhotoParams['pref'] = $jsContactPhotoPref; - } + $kind = strtolower((string) $media->getKind()); - if (isset($jsContactPhotoMediaType) && !empty($jsContactPhotoMediaType)) { - $vCardPhotoParams['mediatype'] = $jsContactPhotoMediaType; - } + if ($kind === null || !isset($kindToVcard[$kind])) { + continue; + } - if (isset($jsContactPhotoValue) && !empty($jsContactPhotoValue)) { - $this->vCard->add("PHOTO", $jsContactPhotoValue, $vCardPhotoParams); - } + $uri = $media->getUri(); + + if (!is_string($uri) || trim($uri) === '') { + continue; } + + $params = $this->buildCommonUriObjectParams($media, $id); + + $this->vCard->add($kindToVcard[$kind], $uri, $params); } } /** - * This function maps the vCard "BDAY", "BIRTHPLACE", "DEATHDATE", "DEATHPLACE" and "ANNIVERSARY" properties - * to the JSContact "anniversaries" property + * This function maps the vCard "SOURCE" and "ORG-DIRECTORY" properties to the JSContact "directories" property * - * @return array|null The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects + * @param ContactCard $card */ - // TODO: Should we format anniversaries to contain only date or date with time as well? - // Currently it's without time - public function getAnniversaries() + public function getDirectories(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } + $map = array(); + $idx = 1; - $jsContactAnniversariesProperty = null; + $sources = array( + 'SOURCE' => 'entry', + 'ORG-DIRECTORY' => 'directory', + ); - // BDAY property mapping - foreach ($this->collectVcardProps("BDAY") as $vCardBirthdayProperty) { - $vCardBirthdayPropertyValue = $vCardBirthdayProperty->getValue(); + foreach ($sources as $propName => $kind) { + $items = $propName === 'ORG-DIRECTORY' + ? $this->vCard->__get('ORG-DIRECTORY') + : $this->vCard->{$propName}; - // Only if the vCard BDAY property indeed has a value, we transform it as a date string to - // follow JSContact's date format and set it as value for "date" in an Anniversary object - // which in turn is an element of "anniversaries" in JSContact - if (isset($vCardBirthdayPropertyValue) && !empty($vCardBirthdayPropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactBirthdayPropertyValue = AdapterUtil::parseDateTime( - $vCardBirthdayPropertyValue, - 'Ymd\THis\Z', - 'Y-m-d' - ); + if (!AdapterUtil::isSetAndNotNull($items) || empty($items)) { + continue; + } - // In case we couldn't parse the BDAY value to JSContact's date format (i.e., it's null), - // set the JSContact value to all zeros (default value) - if (is_null($jsContactBirthdayPropertyValue)) { - $jsContactBirthdayPropertyValue = "0000-00-00"; + foreach ($items as $prop) { + $uri = trim((string) $prop); + if ($uri === '') { + continue; } - $jsContactBirthday = new Anniversary($jsContactBirthdayPropertyValue); - $jsContactBirthday->setType("birth"); + $directory = new Directory($kind, $uri); - // In case BIRTHPLACE is present in the vCard, set it as "place" within the JSContact - // birthday Anniversary object - foreach ($this->collectVcardProps("BIRTHPLACE") as $vCardBirthdayPlaceProperty) { - $vCardBirthdayPlacePropertyValue = $vCardBirthdayPlaceProperty->getValue(); + if (isset($prop['INDEX']) && method_exists($directory, 'setListAs')) { + $rawIndex = trim((string) $prop['INDEX']); + if ($rawIndex !== '' && ctype_digit($rawIndex)) { + $directory->setListAs((int) $rawIndex); + } + } - if (isset($vCardBirthdayPlacePropertyValue) && !empty($vCardBirthdayPlacePropertyValue)) { - $jsContactBirthdayPlace = new Address(); + if (isset($prop['MEDIATYPE'])) { + $directory->setMediaType((string) $prop['MEDIATYPE']); + } - // If place is geo URL, then add it to "coordinates" prop of address, - // else add it to "fullAddress" - if (str_starts_with($vCardBirthdayPlacePropertyValue, "geo:")) { - $jsContactBirthdayPlace->setCoordinates($vCardBirthdayPlacePropertyValue); - } else { - $jsContactBirthdayPlace->setFullAddress($vCardBirthdayPlacePropertyValue); - } + Util::applyCommonContextAndPref($directory, $prop); - $jsContactBirthday->setPlace($jsContactBirthdayPlace); - } + $key = Util::getMapKeyFromPropValue($prop, $uri, 'd', $idx, $map); + $map[$key] = $directory; + } + } - $this->convertAltId( - $vCardBirthdayPlaceProperty, - ["anniversaries", md5($vCardBirthdayPropertyValue), "place"] - ); + if (!empty($map)) { + $card->setDirectories($map); + } + } - if ( - isset($vCardBirthdayPlaceProperty['LANGUAGE']) - && !empty($vCardBirthdayPlaceProperty['LANGUAGE']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered for vCard property BIRTHPLACE" - ); - } - } + /** + * This function maps the JSContact "directories" property to the vCard SOURCE or ORG-DIRECTORY properties + * + * @param ContactCard $card + */ + public function setDirectories(ContactCard $card) + { + $directories = $card->getDirectories(); + if (!is_array($directories) || empty($directories)) { + return; + } - // Since "anniversaries" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the BDAY property's value to create the key of the entry in "anniversaries" - $jsContactAnniversariesProperty[md5($vCardBirthdayPropertyValue)] = $jsContactBirthday; + foreach ($directories as $id => $dir) { + if (!is_object($dir)) { + continue; } - } - // DEATHDATE property mapping - foreach ($this->collectVcardProps("DEATHDATE") as $vCardDeathdateProperty) { - $vCardDeathdatePropertyValue = $vCardDeathdateProperty->getValue(); + $kind = strtolower((string) $dir->getKind()); + $uri = $dir->getUri(); - // Only if the vCard DEATHDATE property indeed has a value, we transform it as a date string to - // follow JSContact's date format and set it as value for "date" in an Anniversary object - // which in turn is an element of "anniversaries" in JSContact - if (isset($vCardDeathdatePropertyValue) && !empty($vCardDeathdatePropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactDeathdatePropertyValue = AdapterUtil::parseDateTime( - $vCardDeathdatePropertyValue, - 'Ymd\THis\Z', - 'Y-m-d' - ); + if ($uri === null || $uri === '') { + continue; + } - // In case we couldn't parse the DEATHDATE value to JSContact's date format (i.e., it's null), - // set the JSContact value to all zeros (default value) - if (is_null($jsContactDeathdatePropertyValue)) { - $jsContactDeathdatePropertyValue = "0000-00-00"; - } + $propName = null; + if ($kind === 'entry') { + $propName = 'SOURCE'; + } elseif ($kind === 'directory') { + $propName = 'ORG-DIRECTORY'; + } - $jsContactDeathdate = new Anniversary($jsContactDeathdatePropertyValue); - $jsContactDeathdate->setType("death"); + if ($propName === null) { + continue; + } - // In case DEATHPLACE is present in the vCard, set it as "place" within the JSContact - // deathdate Anniversary object - foreach ($this->collectVcardProps("DEATHPLACE") as $vCardDeathdatePlaceProperty) { - $vCardDeathdatePlacePropertyValue = $vCardDeathdatePlaceProperty->getValue(); + $params = $this->buildCommonUriObjectParams($dir, $id); - if (isset($vCardDeathdatePlacePropertyValue) && !empty($vCardDeathdatePlacePropertyValue)) { - $jsContactDeathdatePlace = new Address(); + // Add INDEX parameter if present + if (method_exists($dir, 'getListAs')) { + $listAs = $dir->getListAs(); + if (is_int($listAs)) { + $params['INDEX'] = (string) $listAs; + } + } - // If place is geo URL, then add it to "coordinates" prop of address, - // else add it to "fullAddress" - if (str_starts_with($vCardDeathdatePlacePropertyValue, "geo:")) { - $jsContactDeathdatePlace->setCoordinates($vCardDeathdatePlacePropertyValue); - } else { - $jsContactDeathdatePlace->setFullAddress($vCardDeathdatePlacePropertyValue); - } + $this->vCard->add($propName, $uri, $params); + } + } - $jsContactDeathdate->setPlace($jsContactDeathdatePlace); - } + /** + * This function maps the vCard "URL" and "CONTACT-URI" properties to the JSContact "links" property + * + * @param ContactCard $card + */ + public function getLinks(ContactCard $card) + { + $map = array(); + $idx = 1; - $this->convertAltId( - $jsContactDeathdatePlace, - ["anniversaries", md5($vCardDeathdatePropertyValue), "place"] - ); + $sources = array( + 'URL' => null, + 'CONTACT-URI' => 'contact', + ); - if ( - isset($vCardDeathdatePlaceProperty['LANGUAGE']) - && !empty($vCardDeathdatePlaceProperty['LANGUAGE']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered for vCard property DEATHPLACE" - ); - } - } + foreach ($sources as $propName => $kind) { + $items = $propName === 'CONTACT-URI' + ? $this->vCard->__get('CONTACT-URI') + : $this->vCard->{$propName}; - // Since "anniversaries" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the DEATHDATE property's value to create the key of the entry in "anniversaries" - $jsContactAnniversariesProperty[md5($vCardDeathdatePropertyValue)] = $jsContactDeathdate; + if (!AdapterUtil::isSetAndNotNull($items) || empty($items)) { + continue; } - } - // ANNIVERSARY property mapping - foreach ($this->collectVcardProps("ANNIVERSARY") as $vCardAnniversaryProperty) { - $vCardAnniversaryPropertyValue = $vCardAnniversaryProperty->getValue(); + foreach ($items as $prop) { + $uri = trim((string) $prop); + if ($uri === '') { + continue; + } + + $link = new Link($uri); - // Only if the vCard ANNIVERSARY property indeed has a value, we transform it as a date string to - // follow JSContact's date format and set it as value for "date" in an Anniversary object - // which in turn is an element of "anniversaries" in JSContact - if (isset($vCardAnniversaryPropertyValue) && !empty($vCardAnniversaryPropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactAnniversaryPropertyValue = AdapterUtil::parseDateTime( - $vCardAnniversaryPropertyValue, - 'Ymd\THis\Z', - 'Y-m-d' - ); + if (!$link) { + continue; + } + + $link->setUri($uri); + + if ($kind !== null) { + $link->setKind($kind); + } - // In case we couldn't parse the ANNIVERSARY value to JSContact's date format (i.e., it's null), - // set the JSContact value to all zeros (default value) - if (is_null($jsContactAnniversaryPropertyValue)) { - $jsContactAnniversaryPropertyValue = "0000-00-00"; + if (isset($prop['MEDIATYPE'])) { + $link->setMediaType((string) $prop['MEDIATYPE']); } - $jsContactAnniversary = new Anniversary($jsContactAnniversaryPropertyValue); - $jsContactAnniversary->setType("wedding"); + Util::applyCommonContextAndPref($link, $prop); - // Since "anniversaries" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the ANNIVERSARY property value to create the key of the entry in "anniversaries" - $jsContactAnniversariesProperty[md5($vCardAnniversaryPropertyValue)] = $jsContactAnniversary; + $key = Util::getMapKeyFromPropValue($prop, $uri, 'l', $idx, $map); + $map[$key] = $link; } } - return $jsContactAnniversariesProperty; + if (!empty($map)) { + $card->setLinks($map); + } } /** - * This function maps entries of the JSContact "anniversaries" property corresponding to - * the vCard BDAY property to it + * This function maps the JSContact "links" property to the vCard URL or CONTACT-URI properties * - * @param array|null $jsContactAnniversaries The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects + * @param ContactCard $card */ - public function setBDay($jsContactAnniversaries) + public function setLinks(ContactCard $card) { - if (!isset($jsContactAnniversaries) || empty($jsContactAnniversaries)) { + $links = $card->getLinks(); + if (!is_array($links) || empty($links)) { return; } - foreach ($jsContactAnniversaries as $id => $jsContactAnniversary) { - if (isset($jsContactAnniversary) && !empty($jsContactAnniversary)) { - $jsContactAnniversaryType = $jsContactAnniversary->type; - $jsContactAnniversaryValue = $jsContactAnniversary->date; - - if ( - isset($jsContactAnniversaryType) - && !empty($jsContactAnniversaryType) - && strcmp($jsContactAnniversaryType, "birth") === 0 - ) { - if (isset($jsContactAnniversaryValue) && !empty($jsContactAnniversaryValue)) { - $vCardBDayValue = AdapterUtil::parseDateTime( - $jsContactAnniversaryValue, - 'Y-m-d\TH:i:s\Z', - 'Ymd\THis\Z', - 'Y-m-d' - ); - - if (is_null($vCardBDayValue)) { - throw new InvalidArgumentException("Couldn't parse JSContact birth date to vCard BDAY. - JSContact date encountered is: " . $jsContactAnniversaryValue); - return; - } + foreach ($links as $id => $link) { + if (!is_object($link)) { + continue; + } - $this->vCard->add("BDAY", $vCardBDayValue); - } - } + $uri = $link->getUri(); + if ($uri === null || $uri === '') { + continue; } + + $kind = strtolower((string) $link->getKind()); + $propName = ($kind === 'contact') ? 'CONTACT-URI' : 'URL'; + + $params = $this->buildCommonUriObjectParams($link, $id); + + $this->vCard->add($propName, $uri, $params); } } /** - * This function maps entries of the JSContact "anniversaries" property corresponding to - * the vCard BIRTHPLACE property to it + * This function maps the vCard "KEY" property to the JSContact "cryptoKeys" property * - * @param array|null $jsContactAnniversaries The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects + * @param ContactCard $card */ - public function setBirthPlace($jsContactAnniversaries) + public function getCryptoKeys(ContactCard $card) { - if (!isset($jsContactAnniversaries) || empty($jsContactAnniversaries)) { + $items = $this->vCard->KEY; + if (!AdapterUtil::isSetAndNotNull($items) || empty($items)) { return; } - foreach ($jsContactAnniversaries as $id => $jsContactAnniversary) { - if (isset($jsContactAnniversary) && !empty($jsContactAnniversary)) { - $jsContactAnniversaryType = $jsContactAnniversary->type; - $jsContactAnniversaryPlace = $jsContactAnniversary->place; + $map = array(); + $idx = 1; - if ( - isset($jsContactAnniversaryType) - && !empty($jsContactAnniversaryType) - && strcmp($jsContactAnniversaryType, "birth") === 0 - ) { - if (isset($jsContactAnniversaryPlace) && !empty($jsContactAnniversaryPlace)) { - $jsContactAnniversaryPlaceCoordinates = $jsContactAnniversaryPlace->coordinates; - $jsContactAnniversaryPlaceFullAddress = $jsContactAnniversaryPlace->fullAddress; + foreach ($items as $prop) { + $uri = trim((string) $prop); + if ($uri === '') { + continue; + } - if ( - isset($jsContactAnniversaryPlaceCoordinates) - && !empty($jsContactAnniversaryPlaceCoordinates) - ) { - $this->vCard->add("BIRTHPLACE", $jsContactAnniversaryPlaceCoordinates); - } elseif ( - isset($jsContactAnniversaryPlaceFullAddress) - && !empty($jsContactAnniversaryPlaceFullAddress) - ) { - $this->vCard->add("BIRTHPLACE", $jsContactAnniversaryPlaceFullAddress); - } else { - return; - } - } - } + $key = new CryptoKey($uri); + + if (isset($prop['MEDIATYPE'])) { + $key->setMediaType((string) $prop['MEDIATYPE']); } + + Util::applyCommonContextAndPref($key, $prop); + + $mapKey = Util::getMapKeyFromPropValue($prop, $uri, 'k', $idx, $map); + $map[$mapKey] = $key; + } + + if (!empty($map)) { + $card->setCryptoKeys($map); } } /** - * This function maps entries of the JSContact "anniversaries" property corresponding to - * the vCard DEATHDATE property to it + * This function maps the JSContact "cryptoKeys" property to the vCard KEY property * - * @param array|null $jsContactAnniversaries The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects + * @param ContactCard $card */ - public function setDeathDate($jsContactAnniversaries) + public function setCryptoKeys(ContactCard $card) { - if (!isset($jsContactAnniversaries) || empty($jsContactAnniversaries)) { + $keys = $card->getCryptoKeys(); + if (!is_array($keys) || empty($keys)) { return; } - foreach ($jsContactAnniversaries as $id => $jsContactAnniversary) { - if (isset($jsContactAnniversary) && !empty($jsContactAnniversary)) { - $jsContactAnniversaryType = $jsContactAnniversary->type; - $jsContactAnniversaryValue = $jsContactAnniversary->date; - - if ( - isset($jsContactAnniversaryType) - && !empty($jsContactAnniversaryType) - && strcmp($jsContactAnniversaryType, "death") === 0 - ) { - if (isset($jsContactAnniversaryValue) && !empty($jsContactAnniversaryValue)) { - $vCardDeathDateValue = AdapterUtil::parseDateTime( - $jsContactAnniversaryValue, - 'Y-m-d\TH:i:s\Z', - 'Ymd\THis\Z', - 'Y-m-d' - ); - - if (is_null($vCardDeathDateValue)) { - throw new InvalidArgumentException("Couldn't parse JSContact death date to vCard DEATHDATE. - JSContact date encountered is: " . $jsContactAnniversaryValue); - return; - } + foreach ($keys as $id => $key) { + if (!is_object($key)) { + continue; + } - $this->vCard->add("DEATHDATE", $vCardDeathDateValue); - } - } + $uri = $key->getUri(); + if ($uri === null || $uri === '') { + continue; } + + $params = $this->buildCommonUriObjectParams($key, $id); + + $this->vCard->add('KEY', $uri, $params); } } /** - * This function maps entries of the JSContact "anniversaries" property corresponding to - * the vCard DEATHPLACE property to it + * This function maps the vCard "CALADRURI" property to the JSContact "schedulingAddresses" property * - * @param array|null $jsContactAnniversaries The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects + * @param ContactCard $card */ - public function setDeathPlace($jsContactAnniversaries) + public function getSchedulingAddresses(ContactCard $card) { - if (!isset($jsContactAnniversaries) || empty($jsContactAnniversaries)) { + $items = $this->vCard->__get('CALADRURI'); + if (!AdapterUtil::isSetAndNotNull($items) || empty($items)) { return; } - foreach ($jsContactAnniversaries as $id => $jsContactAnniversary) { - if (isset($jsContactAnniversary) && !empty($jsContactAnniversary)) { - $jsContactAnniversaryType = $jsContactAnniversary->type; - $jsContactAnniversaryPlace = $jsContactAnniversary->place; + $map = array(); + $idx = 1; - if ( - isset($jsContactAnniversaryType) - && !empty($jsContactAnniversaryType) - && strcmp($jsContactAnniversaryType, "death") === 0 - ) { - if (isset($jsContactAnniversaryPlace) && !empty($jsContactAnniversaryPlace)) { - $jsContactAnniversaryPlaceCoordinates = $jsContactAnniversaryPlace->coordinates; - $jsContactAnniversaryPlaceFullAddress = $jsContactAnniversaryPlace->fullAddress; + foreach ($items as $prop) { + $uri = trim((string) $prop); + if ($uri === '') { + continue; + } - if ( - isset($jsContactAnniversaryPlaceCoordinates) - && !empty($jsContactAnniversaryPlaceCoordinates) - ) { - $this->vCard->add("DEATHPLACE", $jsContactAnniversaryPlaceCoordinates); - } elseif ( - isset($jsContactAnniversaryPlaceFullAddress) - && !empty($jsContactAnniversaryPlaceFullAddress) - ) { - $this->vCard->add("DEATHPLACE", $jsContactAnniversaryPlaceFullAddress); - } else { - return; - } - } - } + $sched = new SchedulingAddress(); + + if (!$sched) { + continue; } + + $sched->setUri($uri); + + Util::applyCommonContextAndPref($sched, $prop); + + $key = Util::getMapKeyFromPropValue($prop, $uri, 'sa', $idx, $map); + $map[$key] = $sched; + } + + if (!empty($map)) { + $card->setSchedulingAddresses($map); } } /** - * This function maps entries of the JSContact "anniversaries" property corresponding to - * the vCard ANNIVERSARY property to it + * This function maps the JSContact "schedulingAddresses" property to the vCard CALADRURI property * - * @param array|null $jsContactAnniversaries The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects + * @param ContactCard $card */ - public function setAnniversary($jsContactAnniversaries) + public function setSchedulingAddresses(ContactCard $card) { - if (!isset($jsContactAnniversaries) || empty($jsContactAnniversaries)) { + $schedules = $card->getSchedulingAddresses(); + if (!is_array($schedules) || empty($schedules)) { return; } - foreach ($jsContactAnniversaries as $id => $jsContactAnniversary) { - if (isset($jsContactAnniversary) && !empty($jsContactAnniversary)) { - $jsContactAnniversaryLabel = $jsContactAnniversary->label; - $jsContactAnniversaryValue = $jsContactAnniversary->date; - - if ( - isset($jsContactAnniversaryLabel) - && !empty($jsContactAnniversaryLabel) - && strcmp($jsContactAnniversaryLabel, "anniversary") === 0 - ) { - if (isset($jsContactAnniversaryValue) && !empty($jsContactAnniversaryValue)) { - $vCardAnniversaryValue = AdapterUtil::parseDateTime( - $jsContactAnniversaryValue, - 'Y-m-d\TH:i:s\Z', - 'Ymd\THis\Z', - 'Y-m-d' - ); - - if (is_null($vCardAnniversaryValue)) { - throw new InvalidArgumentException( - "Couldn't parse JSContact anniversary date to vCard ANNIVERSARY. - JSContact date encountered is: " . $jsContactAnniversaryValue - ); - return; - } + foreach ($schedules as $id => $sched) { + if (!is_object($sched)) { + continue; + } - $this->vCard->add("ANNIVERSARY", $vCardAnniversaryValue); - } - } + $uri = $sched->getUri(); + if ($uri === null || $uri === '') { + continue; + } + + $params = array(); + + $types = Util::contextsToVcardTypeParam($sched); + if (!empty($types)) { + $params['TYPE'] = $types; } + + $pref = Util::prefToVcardParam($sched); + if ($pref !== null) { + $params['PREF'] = $pref; + } + + $params = Util::addPropIdParam($params, $id); + + $this->vCard->add('CALADRURI', $uri, $params); } } /** - * This function maps the vCard "GENDER" property to the JSContact "speakToAs" property + * This function maps the vCard "CALURI" and "FBURL" properties to the JSContact "calendars" property * - * @return SpeakToAs|null The "speakToAs" JSContact property as a SpeakToAs object + * @param ContactCard $card */ - public function getSpeakToAs() + public function getCalendars(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactSpeakToAsProperty = null; - - // GENDER property mapping - if (in_array("GENDER", $this->vCardChildren)) { - $vCardGenderProperty = $this->vCard->GENDER; + $map = array(); + $idx = 1; - if (isset($vCardGenderProperty)) { - $vCardGenderPropertyValue = $vCardGenderProperty->getValue(); + // Each entry is [vCard property name, JSContact kind]. + $sources = array( + array('CALURI', 'calendar'), + array('FBURL', 'freeBusy'), + ); - if (isset($vCardGenderPropertyValue) && !empty($vCardGenderPropertyValue)) { - $jsContactGrammaticalGenderValue = null; + foreach ($sources as list($propName, $kind)) { + $items = $this->vCard->__get($propName); + if (!AdapterUtil::isSetAndNotNull($items) || empty($items)) { + continue; + } - switch ($vCardGenderPropertyValue) { - case 'M': - $jsContactGrammaticalGenderValue = 'male'; - break; + foreach ($items as $prop) { + $uri = trim((string) $prop); + if ($uri === '') { + continue; + } - case 'F': - $jsContactGrammaticalGenderValue = 'female'; - break; + $calendar = new Calendar(); - case 'N': - $jsContactGrammaticalGenderValue = 'neuter'; - break; + if (!$calendar) { + continue; + } - case 'O': - $jsContactGrammaticalGenderValue = 'animate'; - break; + $calendar->setKind($kind); + $calendar->setUri($uri); - case 'U': - $jsContactGrammaticalGenderValue = null; - break; + if (isset($prop['MEDIATYPE'])) { + $calendar->setMediaType((string) $prop['MEDIATYPE']); + } - default: - $this->logger->warning( - "Unknown vCard GENDER property value encountered: " . $vCardGenderPropertyValue - ); - $this->logger->warning("Setting JSContact grammaticalGender value to null."); - $jsContactGrammaticalGenderValue = null; - break; - } + Util::applyCommonContextAndPref($calendar, $prop); - if (!is_null($jsContactGrammaticalGenderValue)) { - $jsContactSpeakToAsProperty = new SpeakToAs(); - $jsContactSpeakToAsProperty->setAtType("SpeakToAs"); - $jsContactSpeakToAsProperty->setGrammaticalGender($jsContactGrammaticalGenderValue); - } - } + $key = Util::getMapKeyFromPropValue($prop, $uri, 'cal', $idx, $map); + $map[$key] = $calendar; } } - return $jsContactSpeakToAsProperty; + if (!empty($map)) { + $card->setCalendars($map); + } } /** - * This function maps the JSContact "speakToAs" property to the vCard GENDER property + * This function maps the JSContact "calendars" property to the vCard CALURI or FBURL properties * - * @param SpeakToAs|null $jsContactSpeakToAs - * The "speakToAs" JSContact property as a SpeakToAs object + * @param ContactCard $card */ - public function setGender($jsContactSpeakToAs) + public function setCalendars(ContactCard $card) { - if (!isset($jsContactSpeakToAs) || empty($jsContactSpeakToAs)) { + $calendars = $card->getCalendars(); + if (!is_array($calendars) || empty($calendars)) { return; } - $jsContactSpeakToAsGrammaticalGender = $jsContactSpeakToAs->grammaticalGender; - - if (isset($jsContactSpeakToAsGrammaticalGender) && !empty($jsContactSpeakToAsGrammaticalGender)) { - $vCardGenderValue = null; - - switch ($jsContactSpeakToAsGrammaticalGender) { - case 'male': - $vCardGenderValue = 'M'; - break; + foreach ($calendars as $id => $calendar) { + if (!is_object($calendar)) { + continue; + } - case 'female': - $vCardGenderValue = 'F'; - break; + $uri = $calendar->getUri(); + if ($uri === null || $uri === '') { + continue; + } - case 'neuter': - $vCardGenderValue = 'N'; - break; + $kind = strtolower(trim((string) $calendar->getKind())); + if ($kind === 'freebusy') { + $propName = 'FBURL'; + } elseif ($kind === 'calendar') { + $propName = 'CALURI'; + } else { + continue; + } - case 'animate': - $vCardGenderValue = 'O'; - break; + $params = array(); - case 'inanimate': - $vCardGenderValue = 'N;inanimate'; - break; + $mt = $calendar->getMediaType(); + if (is_string($mt) && $mt !== '') { + $params['MEDIATYPE'] = $mt; + } - default: - throw new InvalidArgumentException("Unknown JSContact value for the property \"grammaticalGender\" - of the \"speakToAs\" property used during conversion to vCard GENDER property. - Encountered value is: " . $jsContactSpeakToAsGrammaticalGender); - break; + $types = Util::contextsToVcardTypeParam($calendar); + if (!empty($types)) { + $params['TYPE'] = $types; } - if (!is_null($vCardGenderValue)) { - $this->vCard->add("GENDER", $vCardGenderValue); + $pref = Util::prefToVcardParam($calendar); + if ($pref !== null) { + $params['PREF'] = $pref; } + + $params = Util::addPropIdParam($params, $id); + + $this->vCard->add($propName, $uri, $params); } } /** - * This function maps the vCard "ADR" property to the JSContact "addresses" property + * Writes ADR property with JSCOMPS parameter reconstructed from ordered components. * - * @return array|null The "addresses" JSContact property as a map of IDs to Address objects + * @param Address $address + * @param array $components + * @param string $id */ - public function getAddresses() + protected function setAddressWithJscomps($address, $components, $id) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } + $kindToPosition = Util::getAddressKindToPositionMap(); + $defaultSep = ''; - $jsContactAddressesProperty = null; - - // ADR property mapping - if (in_array("ADR", $this->vCardChildren) && !is_null($this->vCard->ADR)) { - $vCardAddressProperties = $this->vCard->ADR; + if (method_exists($address, 'getDefaultSeparator')) { + $sep = $address->getDefaultSeparator(); + if ($sep !== null && $sep !== '') { + $defaultSep = $sep; + } + } - foreach ($vCardAddressProperties as $vCardAddressProperty) { - if (isset($vCardAddressProperty)) { - $vCardAddressPropertyValue = $vCardAddressProperty->getParts(); + list($parts, $jscompsValue) = Util::buildJscompsData( + $components, + $kindToPosition, + $defaultSep, + 17 + ); - // Obtain the vCard ADR values - $vCardPostOfficeBox = $vCardAddressPropertyValue[0]; - $vCardExtendedAddress = $vCardAddressPropertyValue[1]; - $vCardStreetAddress = $vCardAddressPropertyValue[2]; - $vCardLocality = $vCardAddressPropertyValue[3]; - $vCardRegion = $vCardAddressPropertyValue[4]; - $vCardPostalCode = $vCardAddressPropertyValue[5]; - $vCardCountryName = $vCardAddressPropertyValue[6]; + $params = array('JSCOMPS' => $jscompsValue); - // Create the JSContact Address object and populate it with data below - $jsContactAddress = new Address(); - $jsContactAddress->setAtType("Address"); + $fullAddr = $address->getFullAddress(); + if ($fullAddr) { + $params['LABEL'] = $fullAddr; + } - if (isset($vCardLocality) && !empty($vCardLocality)) { - $jsContactAddress->setLocality($vCardLocality); - } + $countryCode = $address->getCountryCode(); + if ($countryCode) { + $params['CC'] = $countryCode; + } - if (isset($vCardRegion) && !empty($vCardRegion)) { - $jsContactAddress->setRegion($vCardRegion); - } + $coordinates = $address->getCoordinates(); + if ($coordinates) { + $params['GEO'] = $coordinates; + } - if (isset($vCardPostalCode) && !empty($vCardPostalCode)) { - $jsContactAddress->setPostcode($vCardPostalCode); - } + $timeZone = $address->getTimeZone(); + if ($timeZone) { + $params['TZ'] = $timeZone; + } - if (isset($vCardCountryName) && !empty($vCardCountryName)) { - $jsContactAddress->setCountry($vCardCountryName); - } + $types = Util::contextsToVcardTypeParam($address); + if (!empty($types)) { + $params['TYPE'] = $types; + } - $jsContactStreet = []; + $pref = Util::prefToVcardParam($address); + if ($pref !== null) { + $params['PREF'] = $pref; + } - if (isset($vCardPostOfficeBox) && !empty($vCardPostOfficeBox)) { - $jsContactStreet[] = new StreetComponent("postOfficeBox", $vCardPostOfficeBox); - } + $params = Util::addPropIdParam($params, $id); - if (isset($vCardExtendedAddress) && !empty($vCardExtendedAddress)) { - $jsContactStreet[] = new StreetComponent("extension", $vCardExtendedAddress); - } + $this->vCard->add('ADR', $parts, $params); + } - if (isset($vCardStreetAddress) && !empty($vCardStreetAddress)) { - $jsContactStreet[] = new StreetComponent("name", $vCardStreetAddress); - } + /** + * This function maps the JSContact "addresses" property to the vCard ADR property + * + * @param ContactCard $card + */ + public function setAddresses(ContactCard $card) + { + $addresses = $card->getAddresses(); + if (!is_array($addresses)) { + return; + } - $jsContactAddress->setStreet($jsContactStreet); + foreach ($addresses as $id => $address) { + if (!($address instanceof Address)) { + continue; + } + $timeZone = $address->getTimeZone(); + $components = $address->getComponents(); + $fullAddr = $address->getFullAddress(); + $coordinates = $address->getCoordinates(); + $countryCode = $address->getCountryCode(); + $hasComponents = is_array($components) && !empty($components); - // Map the LABEL parameter to "fullAddress" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardAddressProperty['LABEL'])) { - $jsContactAddress->setFullAddress($vCardAddressProperty['LABEL']); - } + // Standalone timezone property + if ($timeZone && !$hasComponents && !$fullAddr && !$coordinates && !$countryCode) { + $this->addSingleProperty('TZ', $timeZone); + continue; + } - // Map the GEO parameter to "coordinates" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardAddressProperty['GEO'])) { - $jsContactAddress->setCoordinates($vCardAddressProperty['GEO']); - } + $isOrdered = method_exists($address, 'getIsOrdered') ? $address->getIsOrdered() : false; - // Map the TZ parameter to "timeZone" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardAddressProperty['TZ'])) { - $jsContactAddress->setTimeZone($vCardAddressProperty['TZ']); - } + if ($isOrdered && $hasComponents) { + $this->setAddressWithJscomps($address, $components, $id); + continue; + } - // Map the CC parameter to "countryCode" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardAddressProperty['CC'])) { - $jsContactAddress->setCountryCode($vCardAddressProperty['CC']); - } + // Initialize 17 ADR parts (RFC 9554 extended format) + $parts = array_fill(0, 17, ''); - // Map the PREF parameter to "pref" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardAddressProperty['PREF'])) { - $jsContactAddress->setPref($vCardAddressProperty['PREF']); - } + // Component kind to index + $kindToIndex = Util::getAddressKindToPositionMap(); - // Map the TYPE parameter to "contexts" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardAddressProperty['TYPE'])) { - $jsContactAddressContexts = []; - - foreach ($vCardAddressProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactAddressContexts['private'] = true; - break; - - case 'work': - $jsContactAddressContexts['work'] = true; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property ADR: " . $paramValue - ); - break; - } - - $jsContactAddress->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactAddressContexts) - ? $jsContactAddressContexts - : null - ); - } + if ($hasComponents) { + foreach ($components as $comp) { + if (!is_object($comp)) { + continue; } - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if ( - isset($vCardAddressProperty['ALTID']) - && !empty($vCardAddressProperty['ALTID']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered - for vCard property ADR" - ); - } + $kind = $comp->getKind(); + $value = $comp->getValue(); - if ( - isset($vCardAddressProperty['LANGUAGE']) - && !empty($vCardAddressProperty['LANGUAGE']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered - for vCard property ADR" - ); + if (isset($kindToIndex[$kind]) && is_string($value) && $value !== '') { + $parts[$kindToIndex[$kind]] = $value; } - - // Since "addresses" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of a JSContact address object's serialized string as the key for this same - // object that is the corresponding value to this key in "addresses" - $jsContactAddressesProperty[md5(serialize($jsContactAddress))] = $jsContactAddress; } } - } - // After we've converted all ADR properties to "addresses", we check if the TZ property in vCard is set - // If it's set, then we create an extra entry in "addresses" that contains the inforamtion from TZ - if (in_array("TZ", $this->vCardChildren)) { - $vCardTimeZoneProperty = $this->vCard->TZ; - - if (isset($vCardTimeZoneProperty) && !empty($vCardTimeZoneProperty)) { - $vCardTimeZonePropertyValue = $vCardTimeZoneProperty->getValue(); - - if (isset($vCardTimeZonePropertyValue) && !empty($vCardTimeZonePropertyValue)) { - // If the TZ value is a known time zone identifier, then we set it in the "addresses" entry - // Otherwise, we just log an error - if (in_array($vCardTimeZonePropertyValue, timezone_identifiers_list())) { - $jsContactTimeZoneAddressEntry = new Address(); - $jsContactTimeZoneAddressEntry->setAtType("Address"); - $jsContactTimeZoneAddressEntry->setTimeZone($vCardTimeZonePropertyValue); - - // In order to be able to differentiate between JSContact addresses corresponding - // to ADR and TZ, for those that correspond to TZ we set the Address object's label - // to "timezone", so that we can clearly know we have to convert it to TZ when - // converting from JSContact to vCard - $jsContactTimeZoneAddressEntry->setLabel("timezone"); - - $jsContactAddressesProperty[md5($vCardTimeZonePropertyValue)] - = $jsContactTimeZoneAddressEntry; - } else { - $this->logger->error( - "Unknown time zone identifier provided as value for - the vCard TZ property: " . $vCardTimeZonePropertyValue - ); - } - } + // if no components mapped and we have fullAddr, use it as street + $hasAny = (bool) array_filter($parts); + if (!$hasAny && $fullAddr) { + $parts[2] = $fullAddr; + } + + $params = array(); + + if ($countryCode) { + $params['CC'] = $countryCode; + } + if ($coordinates) { + $params['GEO'] = $coordinates; + } + if ($timeZone) { + $params['TZ'] = $timeZone; + } + if ($fullAddr) { + $params['LABEL'] = $fullAddr; + } + + $types = Util::contextsToVcardTypeParam($address); + if (!empty($types)) { + $params['TYPE'] = $types; + } + + $pref = Util::prefToVcardParam($address); + if ($pref !== null) { + $params['PREF'] = $pref; } - } - return $jsContactAddressesProperty; + $params = Util::addPropIdParam($params, $id); + + $this->vCard->add('ADR', $parts, $params); + } } /** - * This function maps the JSContact "addresses" property to the vCard ADR property + * This function maps the vCard "ADR" property to the JSContact "addresses" property * - * @param array|null $jsContactAddresses - * The "addresses" JSContact property as a map of string to Address objects + * @param ContactCard $card */ - public function setADR($jsContactAddresses) + public function getAddresses(ContactCard $card) { - if (!isset($jsContactAddresses) || empty($jsContactAddresses)) { + $vAddrs = $this->vCard->ADR; + if (!AdapterUtil::isSetAndNotNull($vAddrs) || empty($vAddrs)) { return; } - foreach ($jsContactAddresses as $id => $jsContactAddress) { - if (isset($jsContactAddress) && !empty($jsContactAddress)) { - $jsContactAddressLocality = $jsContactAddress->locality; - $jsContactAddressRegion = $jsContactAddress->region; - $jsContactAddressPostcode = $jsContactAddress->postcode; - $jsContactAddressCountry = $jsContactAddress->country; - $jsContactAddressStreet = $jsContactAddress->street; - - $vCardPostOfficeBox = null; - $vCardExtendedAddress = null; - $vCardStreetAddress = null; - $vCardLocality = null; - $vCardRegion = null; - $vCardPostalCode = null; - $vCardCountryName = null; - - if (isset($jsContactAddressLocality) && !empty($jsContactAddressLocality)) { - $vCardLocality = $jsContactAddressLocality; - } + $map = array(); + $i = 1; - if (isset($jsContactAddressRegion) && !empty($jsContactAddressRegion)) { - $vCardRegion = $jsContactAddressRegion; - } + foreach ($vAddrs as $vAddr) { + if (!($vAddr instanceof Property)) { + continue; + } - if (isset($jsContactAddressPostcode) && !empty($jsContactAddressPostcode)) { - $vCardPostalCode = $jsContactAddressPostcode; - } + $parts = $vAddr->getParts(); + if (!AdapterUtil::isSetAndNotNull($parts) || empty($parts)) { + continue; + } - if (isset($jsContactAddressCountry) && !empty($jsContactAddressCountry)) { - $vCardCountryName = $jsContactAddressCountry; - } + $a = new Address(); - if (isset($jsContactAddressStreet) && !empty($jsContactAddressStreet)) { - foreach ($jsContactAddressStreet as $jsContactAddressStreetComponent) { - if (isset($jsContactAddressStreetComponent) && !empty($jsContactAddressStreetComponent)) { - $jsContactAddressStreetComponentValue = $jsContactAddressStreetComponent->value; - if ( - isset($jsContactAddressStreetComponentValue) - && !empty($jsContactAddressStreetComponentValue) - ) { - $jsContactAddressStreetComponentType = $jsContactAddressStreetComponent->type; - if ( - isset($jsContactAddressStreetComponentType) - && !empty($jsContactAddressStreetComponentType) - ) { - switch ($jsContactAddressStreetComponentType) { - case 'postOfficeBox': - $vCardPostOfficeBox = $jsContactAddressStreetComponentValue; - break; - - case 'extension': - $vCardExtendedAddress = $jsContactAddressStreetComponentValue; - break; - - case 'name': - $vCardStreetAddress = $jsContactAddressStreetComponentValue; - break; - - default: - throw new InvalidArgumentException( - "Encountered value for StreetComponent's \"type\" - property which is neither \"postOfficeBox\", nor \"extension\", nor - \"name\" during conversion from JSContact's \"addresses\" property to - vCard's ADR property. Encountered value is: " - . $jsContactAddressStreetComponentType - ); - break; - } - } - } - } - } - } + if (isset($vAddr['LABEL'])) { + $a->setFullAddress((string) $vAddr['LABEL']); + } - // ADR parameter writing - $jsContactAddressFullAddress = $jsContactAddress->fullAddress; - $jsContactAddressCoordinates = $jsContactAddress->coordinates; - $jsContactAddressTimeZone = $jsContactAddress->timeZone; - $jsContactAddressCountryCode = $jsContactAddress->countryCode; - $jsContactAddressPref = $jsContactAddress->pref; - $jsContactAddressContexts = $jsContactAddress->contexts; + $hasJscomps = isset($vAddr['JSCOMPS']); - $vCardAdrParams = []; + if ($hasJscomps) { + $jscompsValue = (string) $vAddr['JSCOMPS']; + $positionToKind = Util::getAddressPositionToKindMap(); - if (isset($jsContactAddressFullAddress) && !empty($jsContactAddressFullAddress)) { - $vCardAdrParams['LABEL'] = $jsContactAddressFullAddress; - } + $components = Util::parseJscompsData( + $jscompsValue, + $parts, + $positionToKind, + new AddressComponent() + ); - if (isset($jsContactAddressCoordinates) && !empty($jsContactAddressCoordinates)) { - $vCardAdrParams['GEO'] = $jsContactAddressCoordinates; - } + if (!empty($components)) { + $a->setComponents($components); + $a->setIsOrdered(true); - if (isset($jsContactAddressTimeZone) && !empty($jsContactAddressTimeZone)) { - $vCardAdrParams['TZ'] = $jsContactAddressTimeZone; + $defaultSep = Util::getDefaultSeparatorFromJscomps($jscompsValue); + if ($defaultSep !== null) { + $a->setDefaultSeparator($defaultSep); + } } + } else { + $isExtended = count($parts) > 7; + $positionToKind = $isExtended + ? Util::getAddressPositionToKindMap() + : Util::getBasicAddressPositionToKindMap(); - if (isset($jsContactAddressCountryCode) && !empty($jsContactAddressCountryCode)) { - $vCardAdrParams['CC'] = $jsContactAddressCountryCode; - } + $components = Util::buildComponentsFromParts( + $parts, + $positionToKind, + new AddressComponent() + ); - if (isset($jsContactAddressPref)) { - $vCardAdrParams['PREF'] = $jsContactAddressPref; + if (!empty($components)) { + $a->setComponents($components); + $a->setIsOrdered(true); + $a->setDefaultSeparator(', '); } + } - if (isset($jsContactAddressContexts) && !empty($jsContactAddressContexts)) { - $vCardAdrTypes = []; - - foreach ($jsContactAddressContexts as $jsContactAddressContext => $booleanValue) { - if (isset($jsContactAddressContext) && !empty($jsContactAddressContext)) { - switch ($jsContactAddressContext) { - case 'private': - $vCardAdrTypes[] = 'home'; - break; - - case 'work': - $vCardAdrTypes[] = 'work'; - break; - - default: - throw new InvalidArgumentException( - "Unknown value for the JSContact property \"contexts\" - encountered during conversion from JSContact's \"addresses\" property to - vCard's ADR property. The encountered value is: " . $jsContactAddressContext - ); - break; - } + // Generate fullAddress if not set + if ($a->getFullAddress() === null) { + $comps = $a->getComponents(); + if (!empty($comps)) { + $fullParts = array(); + foreach ($comps as $comp) { + $val = $comp->getValue(); + if ($val !== null && $val !== '') { + $fullParts[] = $val; } } - - $vCardAdrParams['TYPE'] = $vCardAdrTypes; + if (!empty($fullParts)) { + $a->setFullAddress(implode(', ', $fullParts)); + } } + } - $this->vCard->add( - "ADR", - [ - $vCardPostOfficeBox, - $vCardExtendedAddress, - $vCardStreetAddress, - $vCardLocality, - $vCardRegion, - $vCardPostalCode, - $vCardCountryName - ], - $vCardAdrParams - ); + if (isset($vAddr['CC'])) { + $a->setCountryCode((string) $vAddr['CC']); + } + if (isset($vAddr['GEO'])) { + $a->setCoordinates((string) $vAddr['GEO']); + } + if (isset($vAddr['TZ'])) { + $a->setTimeZone((string) $vAddr['TZ']); } - } - } - /** - * This function maps the corresponding entry in the JSContact "addresses" property to the vCard TZ property - * - * @param array|null $jsContactAddresses - * The "addresses" JSContact property as a map of string to Address objects - */ - public function setTZ($jsContactAddresses) - { - if (!isset($jsContactAddresses) || empty($jsContactAddresses)) { - return; + $ctx = Util::vCardTypeParamToContexts($vAddr); + if (!empty($ctx)) { + $a->setContexts($ctx); + } + + $pref = Util::vCardPrefParamToInt($vAddr); + if ($pref !== null) { + $a->setPref($pref); + } + + $key = Util::getMapKeyFromPropValue($vAddr, json_encode($parts), 'a', $i, $map); + $map[$key] = $a; } - foreach ($jsContactAddresses as $id => $jsContactAddress) { - if (isset($jsContactAddress) && !empty($jsContactAddress)) { - $jsContactAddressTimeZone = $jsContactAddress->timeZone; - $jsContactAddressLabel = $jsContactAddress->label; - - // We use the Address object's label property from JSContact to - // find out here if we're dealing with an entry in the "addresses" - // JSContact property that corresponds to the vCard TZ property - if ( - isset($jsContactAddressLabel) - && !empty($jsContactAddressLabel) - && strcmp($jsContactAddressLabel, "timezone") - ) { - if (isset($jsContactAddressTimeZone) && !empty($jsContactAddressTimeZone)) { - $this->vCard->add("TZ", $jsContactAddressTimeZone); - } - } + $standaloneTz = $this->vCard->__get('TZ'); + if (AdapterUtil::isSetAndNotNull($standaloneTz)) { + $tzValue = trim((string) $standaloneTz); + if ($tzValue !== '') { + $a = new Address(); + $a->setTimeZone($tzValue); + $map['a' . $i++] = $a; } } + + if (!empty($map)) { + $card->setAddresses($map); + } } /** - * This function maps the vCard "TEL" property to the JSContact "phones" property - * - * @return array|null The "addresses" JSContact property as a map of IDs to Phone objects + * Returns the BDAY date as Y-m-d, or '0000-00-00' if it's missing or can't be parsed. + * Handles both plain dates and date-time values like 19950505T000000Z. */ - public function getPhones() + protected function getBirthday() { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; + $bday = $this->vCard->BDAY; + if (!AdapterUtil::isSetAndNotNull($bday)) { + return '0000-00-00'; } - $jsContactPhonesProperty = null; - - // TEL property mapping - if (in_array("TEL", $this->vCardChildren) && !is_null($this->vCard->TEL)) { - $vCardPhoneProperties = $this->vCard->TEL; - - foreach ($vCardPhoneProperties as $vCardPhoneProperty) { - if (isset($vCardPhoneProperty)) { - $vCardPhonePropertyValue = $vCardPhoneProperty->getValue(); - - if (isset($vCardPhonePropertyValue) && !empty($vCardPhonePropertyValue)) { - $jsContactPhone = new Phone(); - $jsContactPhone->setAtType("Phone"); - $jsContactPhone->setPhone($vCardPhonePropertyValue); - - // Map the TYPE parameter to "contexts" or "features" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardPhoneProperty['TYPE'])) { - $jsContactPhoneContexts = []; - $jsContactPhoneFeatures = []; - $jsContactPhoneLabels = []; - - // The TYPE parameter can have multiple values and hence be an array. That's why we iterate - // over its values below for conversion purposes - foreach ($vCardPhoneProperty['TYPE'] as $paramValue) { - // The 'home' and 'work' TYPE values are put into the "contexts" property - // The rest of the phone-related values are put into the "features" property - // Finally, anything else (i.e., unknown values) is put into the "labels" property - switch ($paramValue) { - case 'home': - $jsContactPhoneContexts['private'] = true; - break; - - case 'work': - $jsContactPhoneContexts['work'] = true; - break; - - // If we encounter 'other' as a value from vCard, we need to set the JSContact - // "contexts" property to null - case 'other': - $jsContactPhoneContexts = null; - break; - - case 'text': - $jsContactPhoneFeatures['text'] = true; - break; - - case 'voice': - $jsContactPhoneFeatures['voice'] = true; - break; - - case 'fax': - $jsContactPhoneFeatures['fax'] = true; - break; - - case 'cell': - $jsContactPhoneFeatures['cell'] = true; - break; - - case 'video': - $jsContactPhoneFeatures['video'] = true; - break; - - case 'pager': - $jsContactPhoneFeatures['pager'] = true; - break; - - case 'textphone': - $jsContactPhoneFeatures['textphone'] = true; - break; - - default: - $jsContactPhoneLabels[] = $paramValue; - break; - } - } - - $jsContactPhone->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactPhoneContexts) - ? $jsContactPhoneContexts - : null - ); - - $jsContactPhone->setFeatures( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactPhoneFeatures) - ? $jsContactPhoneFeatures - : null - ); - - $jsContactPhone->setLabel( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactPhoneLabels) - ? implode(",", $jsContactPhoneLabels) - : null - ); - } - - // Map the PREF parameter to "pref" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardPhoneProperty['PREF'])) { - $jsContactPhone->setPref($vCardPhoneProperty['PREF']); - } + $raw = trim((string) $bday); + if ($raw === '') { + return '0000-00-00'; + } - // Since "phones" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the vCard TEL's value as the key for the JSContact Phone - // object that corresponds to this key in "phones" - $jsContactPhonesProperty[md5($vCardPhonePropertyValue)] = $jsContactPhone; - } - } - } + $utc = Util::parseDateTimeToJscontactUtc($raw); + if ($utc !== null) { + return substr($utc, 0, 10); } - return $jsContactPhonesProperty; + $jmap = AdapterUtil::parseDateTime($raw, 'Y-m-d', 'Y-m-d', 'Ymd'); + return $jmap === null ? '0000-00-00' : $jmap; } /** - * This function maps the JSContact "phones" property to the vCard TEL property - * - * @param array|null $jsContactPhones - * The "phones" JSContact property as a map of strings to Phone objects + * Writes a Y-m-d birthday to the vCard BDAY property. */ - public function setTel($jsContactPhones) + protected function setBirthday($birthday) { - if (!isset($jsContactPhones) || empty($jsContactPhones)) { - return; - } - - foreach ($jsContactPhones as $id => $jsContactPhone) { - if (isset($jsContactPhone) && !empty($jsContactPhone)) { - $jsContactPhoneValue = $jsContactPhone->phone; - $jsContactPhoneContexts = $jsContactPhone->contexts; - $jsContactPhoneFeatures = $jsContactPhone->features; - $jsContactPhoneLabels = $jsContactPhone->label; - $jsContactPhonePref = $jsContactPhone->pref; - - $vCardTelParams = []; - - if (isset($jsContactPhoneValue) && !empty($jsContactPhoneValue)) { - if (isset($jsContactPhoneContexts) && !empty($jsContactPhoneContexts)) { - foreach ($jsContactPhoneContexts as $jsContactPhoneContext => $booleanValue) { - switch ($jsContactPhoneContext) { - case 'private': - $vCardTelParams['type'][] = 'home'; - break; - - case 'work': - $vCardTelParams['type'][] = 'work'; - break; - - default: - throw new InvalidArgumentException( - "Unknown value encountered for the \"contexts\" - JSContact property of a JSContact Phone object during conversion from - JSContact's \"phones\" property to vCard's TEL property. - Encountered value is: " . $jsContactPhoneContext - ); - break; - } - } - } else { // In case that $jsContactPhoneContexts is null, we set the vCard type to be 'other' - $vCardTelParams['type'][] = 'other'; - } - - if (isset($jsContactPhoneFeatures) && !empty($jsContactPhoneFeatures)) { - foreach ($jsContactPhoneFeatures as $jsContactPhoneFeature => $booleanValue) { - $vCardTelParams['type'][] = $jsContactPhoneFeature; - } - } - - if (isset($jsContactPhoneLabels) && !empty($jsContactPhoneLabels)) { - // Since $jsContactPhoneLabels is a string that contains one or more values separated - // by a comma, we need to turn it into an array by calling explode() with comma as delimiter - $jsContactPhoneLabels = explode(',', $jsContactPhoneLabels); - - foreach ($jsContactPhoneLabels as $jsContactPhoneLabel) { - $vCardTelParams['type'][] = $jsContactPhoneLabel; - } - } - - if (isset($jsContactPhonePref)) { - $vCardTelParams['pref'] = $jsContactPhonePref; - } - - $this->vCard->add("TEL", $jsContactPhoneValue, $vCardTelParams); - } - } + $vDate = Util::parseDateToVcardDate($birthday); + if ($vDate !== null) { + $this->addSingleProperty('BDAY', $vDate, array('VALUE' => 'date')); } } /** - * This function maps the vCard "EMAIL" property to the JSContact "emails" property - * - * @return array|null The "emails" JSContact property as a map of IDs to EmailAddress objects + * Returns the ANNIVERSARY date as Y-m-d, or '0000-00-00' if it's missing or can't be parsed. + * Handles both plain dates and date-time values like 20051010T000000Z. */ - public function getEmails() + protected function getAnniversary() { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; + $ann = $this->vCard->__get('ANNIVERSARY'); + if (!AdapterUtil::isSetAndNotNull($ann)) { + return '0000-00-00'; } - $jsContactEmailsProperty = null; - - // EMAIL property mapping - if (in_array("EMAIL", $this->vCardChildren) && !is_null($this->vCard->EMAIL)) { - $vCardEmailProperties = $this->vCard->EMAIL; - - foreach ($vCardEmailProperties as $vCardEmailProperty) { - if (isset($vCardEmailProperty)) { - $vCardEmailPropertyValue = $vCardEmailProperty->getValue(); - - if (isset($vCardEmailPropertyValue) && !empty($vCardEmailPropertyValue)) { - $jsContactEmail = new EmailAddress(); - $jsContactEmail->setAtType("EmailAddress"); - $jsContactEmail->setEmail($vCardEmailPropertyValue); - - // Map the TYPE parameter to the "context" property - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardEmailProperty['TYPE'])) { - $jsContactEmailContexts = []; - - foreach ($vCardEmailProperty['TYPE'] as $paramValue) { - switch (strtolower($paramValue)) { - case 'home': - $jsContactEmailContexts['private'] = true; - break; - - case 'work': - $jsContactEmailContexts['work'] = true; - break; - - case 'other': - $jsContactEmailContexts = null; - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property EMAIL: " . $paramValue - ); - break; - } - } - - $jsContactEmail->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactEmailContexts) - ? $jsContactEmailContexts - : null - ); - } - - // Map the PREF parameter to the "pref" property - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardEmailProperty['PREF'])) { - $jsContactEmail->setPref($vCardEmailProperty['PREF']); - } + $raw = trim((string) $ann); + if ($raw === '') { + return '0000-00-00'; + } - $jsContactEmailsProperty[md5($vCardEmailPropertyValue)] = $jsContactEmail; - } - } - } + $utc = Util::parseDateTimeToJscontactUtc($raw); + if ($utc !== null) { + return substr($utc, 0, 10); } - return $jsContactEmailsProperty; + $jmap = AdapterUtil::parseDateTime($raw, 'Ymd', 'Y-m-d', 'Y-m-d'); + return $jmap === null ? '0000-00-00' : $jmap; } /** - * This function maps the JSContact "emails" property to the vCard EMAIL property - * - * @param array|null $jsContactEmails - * The "emails" JSContact property as a map of strings to EmailAddress objects + * Writes a Y-m-d anniversary to the vCard ANNIVERSARY property. */ - public function setEmail($jsContactEmails) + protected function setAnniversary($anniversary) { - if (!isset($jsContactEmails) || empty($jsContactEmails)) { - return; + $vDate = Util::parseDateToVcardDate($anniversary); + if ($vDate !== null) { + $this->addSingleProperty('ANNIVERSARY', $vDate, array('VALUE' => 'date')); } + } - foreach ($jsContactEmails as $id => $jsContactEmail) { - if (isset($jsContactEmail) && !empty($jsContactEmail)) { - $jsContactEmailValue = $jsContactEmail->email; - $jsContactEmailContexts = $jsContactEmail->contexts; - $jsContactEmailPref = $jsContactEmail->pref; - - $vCardEmailParams = []; - - if (isset($jsContactEmailValue) && !empty($jsContactEmailValue)) { - if (isset($jsContactEmailContexts) && !empty($jsContactEmailContexts)) { - foreach ($jsContactEmailContexts as $jsContactEmailContext => $booleanValue) { - switch ($jsContactEmailContext) { - case 'private': - $vCardEmailParams['type'][] = 'home'; - break; - - case 'work': - $vCardEmailParams['type'][] = 'work'; - break; - - default: - throw new InvalidArgumentException( - "Unknown value encountered for the \"contexts\" - JSContact property of a JSContact EmailAddress object during conversion from - JSContact's \"emails\" property to vCard's EMAIL property. - Encountered value is: " . $jsContactEmailContext - ); - break; - } - } - } else { // In case that $jsContactEmailContexts is null, we set the vCard type to be 'other' - $vCardEmailParams['type'][] = 'other'; - } - - if (isset($jsContactEmailPref)) { - $vCardEmailParams['pref'] = $jsContactEmailPref; - } - - $this->vCard->add("EMAIL", $jsContactEmailValue, $vCardEmailParams); - } - } + /** + * Returns the raw BIRTHPLACE value, or null if it's not set. + * Unescapes any literal \n sequences in the value. + */ + protected function getBirthPlaceRaw() + { + $p = $this->vCard->__get('BIRTHPLACE'); + if (!AdapterUtil::isSetAndNotNull($p)) { + return null; } + $raw = str_replace("\\n", "\n", (string) $p); + $raw = trim($raw); + return $raw === '' ? null : $raw; } /** - * This function maps the vCard "LANG" property to the JSContact "preferredContactLanguages" property - * - * @return array>|null - * The "preferredContactLanguages" JSContact property as a map of IDs to arrays fo ContactLanguage objects + * Returns the DEATHDATE as Y-m-d, or '0000-00-00' if it's missing or can't be parsed. */ - public function getPreferredContactLanguages() + protected function getDeathDate() { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; + $p = $this->vCard->__get('DEATHDATE'); + if (!AdapterUtil::isSetAndNotNull($p)) { + return '0000-00-00'; } - $jsContactPreferredContactLanguagesProperty = null; - - // LANG property mapping - if (in_array("LANG", $this->vCardChildren)) { - $vCardLangProperties = $this->vCard->LANG; - - foreach ($vCardLangProperties as $vCardLangProperty) { - if (isset($vCardLangProperty)) { - $vCardLangPropertyValue = $vCardLangProperty->getValue(); - - if (isset($vCardLangPropertyValue) && !empty($vCardLangPropertyValue)) { - // According to the IETF draft: - // "If both PREF and TYPE parameters are missing, the array of - // "ContactLanguage" objects MUST be empty. - if ( - !AdapterUtil::isSetNotNullAndNotEmpty($vCardLangProperty['PREF']) - && !AdapterUtil::isSetNotNullAndNotEmpty($vCardLangProperty['TYPE']) - ) { - $jsContactPreferredContactLanguagesProperty[$vCardLangPropertyValue] = []; - continue; - } - - $jsContactLangEntry = new ContactLanguage(); - $jsContactLangEntry->setAtType("ContactLanguage"); - - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardLangProperty['TYPE'])) { - switch ($vCardLangProperty['TYPE']) { - case 'home': - $jsContactLangEntry->setContext('private'); - break; - - case 'work': - $jsContactLangEntry->setContext('work'); - break; - - default: - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property LANG: " . $vCardLangProperty['TYPE'] - ); - break; - } - } - - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardLangProperty['PREF'])) { - $jsContactLangEntry->setPref($vCardLangProperty['PREF']); - } + $raw = trim((string) $p); + if ($raw === '') { + return '0000-00-00'; + } - // The "preferredContactLanguages" property is a map and the key is the value of - // vCard LANG property while the key is the ContactLanguage object we just created above - $jsContactPreferredContactLanguagesProperty[$vCardLangPropertyValue] = $jsContactLangEntry; - } - } - } + $utc = Util::parseDateTimeToJscontactUtc($raw); + if ($utc !== null) { + return substr($utc, 0, 10); } - return $jsContactPreferredContactLanguagesProperty; + $jmap = AdapterUtil::parseDateTime($raw, 'Y-m-d', 'Y-m-d', 'Ymd'); + return $jmap === null ? '0000-00-00' : $jmap; } /** - * This function maps the JSContact "preferredContactLanguages" property to the vCard LANG property - * - * @param array>|null $jsContactPreferredContactLanguages - * The "preferredContactLanguages" JSContact property as a map of strings to arrays of ContactLanguage objects + * Writes a Y-m-d death date to the vCard DEATHDATE property. */ - public function setLang($jsContactPreferredContactLanguages) + protected function setDeathDate($deathDate) { - if (!isset($jsContactPreferredContactLanguages) || empty($jsContactPreferredContactLanguages)) { - return; - } - - foreach ($jsContactPreferredContactLanguages as $languageTag => $jsContactPreferredContactLanguageArray) { - if (isset($languageTag) && !empty($languageTag)) { - if (isset($jsContactPreferredContactLanguageArray) && !empty($jsContactPreferredContactLanguageArray)) { - $vCardLangParams = []; - - foreach ($jsContactPreferredContactLanguageArray as $jsContactPreferredContactLanguage) { - $jsContactPreferredContactLanguageContext = $jsContactPreferredContactLanguage->context; - $jsContactPreferredContactLanguagePref = $jsContactPreferredContactLanguage->pref; - - if ( - isset($jsContactPreferredContactLanguageContext) - && !empty($jsContactPreferredContactLanguageContext) - ) { - $vCardLangParams['type'][] = $jsContactPreferredContactLanguageContext; - } - - if ( - isset($jsContactPreferredContactLanguagePref) - && !empty($jsContactPreferredContactLanguagePref) - ) { - $vCardLangParams['pref'][] = $jsContactPreferredContactLanguagePref; - } - } - - $this->vCard->add("LANG", $languageTag, $vCardLangParams); - } - } + $vDate = Util::parseDateToVcardDate($deathDate); + if ($vDate !== null) { + $this->addSingleProperty('DEATHDATE', $vDate, array('VALUE' => 'date')); } } /** - * This function maps the vCard "TITLE" and "ROLE" properties to the JSContact "titles" property + * Returns the raw DEATHPLACE value, or null if it's not set. * - * @return array|null The "titles" JSContact property as a map of IDs to Title objects + * @return string|null */ - public function getTitles() + protected function getDeathPlaceRaw() { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactTitlesProperty = null; - - // TITLE property mapping - if (in_array("TITLE", $this->vCardChildren)) { - $vCardTitleProperties = $this->vCard->TITLE; - - foreach ($vCardTitleProperties as $vCardTitleProperty) { - if (isset($vCardTitleProperty)) { - $vCardTitlePropertyValue = $vCardTitleProperty->getValue(); - - if (isset($vCardTitlePropertyValue) && !empty($vCardTitlePropertyValue)) { - $jsContactTitleEntry = new Title(); - $jsContactTitleEntry->setAtType("Title"); - $jsContactTitleEntry->setTitle($vCardTitlePropertyValue); - - $jsContactTitlesProperty[md5($vCardTitlePropertyValue)] = $jsContactTitleEntry; - } - - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if ( - isset($vCardTitleProperty['ALTID']) - && !empty($vCardTitleProperty['ALTID']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered - for vCard property TITLE" - ); - } - - if ( - isset($vCardTitleProperty['LANGUAGE']) - && !empty($vCardTitleProperty['LANGUAGE']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered - for vCard property TITLE" - ); - } - } - } - } - - // ROLE property mapping - if (in_array("ROLE", $this->vCardChildren)) { - $vCardRoleProperties = $this->vCard->ROLE; - - foreach ($vCardRoleProperties as $vCardRoleProperty) { - if (isset($vCardRoleProperty)) { - $vCardRolePropertyValue = $vCardRoleProperty->getValue(); - - if (isset($vCardRolePropertyValue) && !empty($vCardRolePropertyValue)) { - $jsContactRoleEntry = new Title(); - $jsContactRoleEntry->setAtType("Title"); - $jsContactRoleEntry->setTitle($vCardRolePropertyValue); - - $jsContactTitlesProperty[md5($vCardRolePropertyValue)] = $jsContactRoleEntry; - } - - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if ( - isset($vCardRoleProperty['ALTID']) - && !empty($vCardRoleProperty['ALTID']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered - for vCard property ROLE" - ); - } - - if ( - isset($vCardRoleProperty['LANGUAGE']) - && !empty($vCardRoleProperty['LANGUAGE']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered - for vCard property ROLE" - ); - } - } - } + $p = $this->vCard->__get('DEATHPLACE'); + if (!AdapterUtil::isSetAndNotNull($p)) { + return null; } - - return $jsContactTitlesProperty; + $raw = str_replace("\\n", "\n", (string) $p); + $raw = trim($raw); + return $raw === '' ? null : $raw; } /** - * This function maps the JSContact "titles" property to the vCard TITLE property + * Converts a raw place string (plain text or geo: URI) to a JSContact Address object. * - * @param array|null $jsContactTitles - * The "titles" JSContact property as a map of strings to Title objects + * @param string $raw + * @return Address|null */ - public function setTitle($jsContactTitles) + protected function placeRawToAddress($raw) { - if (!isset($jsContactTitles) || empty($jsContactTitles)) { - return; - } - - foreach ($jsContactTitles as $id => $jsContactTitle) { - if (isset($jsContactTitle) && !empty($jsContactTitle)) { - $jsContactTitleValue = $jsContactTitle->title; - if (isset($jsContactTitleValue) && !empty($jsContactTitleValue)) { - $this->vCard->add("TITLE", $jsContactTitleValue); - } - } - } + return Util::placeToAddress($raw, $this->placeTextAsFullAddress); } /** - * This function maps the vCard "ORG" property to the JSContact "organizations" property + * Pulls the place value (coordinates or text) out of a JSContact Address so it can be + * written to a BIRTHPLACE or DEATHPLACE vCard property. * - * @return array|null - * The "organizations" JSContact property as a map of IDs to Organization objects + * @param Address $addr + * @return string|null */ - public function getOrganizations() + protected function getPlaceValueFromAddress(Address $addr) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactOrganizationsProperty = null; - - // ORG property mapping - foreach ($this->collectVcardProps("ORG") as $vCardOrgProperty) { - $vCardOrgPropertyValue = $vCardOrgProperty->getValue(); - - if (isset($vCardOrgPropertyValue) && !empty($vCardOrgPropertyValue)) { - $jsContactOrganization = new Organization(); - - if (strpos($vCardOrgPropertyValue, ';') !== false) { - $orgValues = explode(';', $vCardOrgPropertyValue); - $jsContactOrganization->setName(array_shift($orgValues)); - - $units = []; - foreach ($orgValues as $unit) { - // TODO Use OrgUnit here in a future version. - // WARNING: This will then break deserialization in jmap-java - array_push($units, $unit); - } - $jsContactOrganization->setUnits($units); - } else { - $jsContactOrganization->setName($vCardOrgPropertyValue); - } - - $jsContactOrganizationsProperty[md5($vCardOrgPropertyValue[0])] = $jsContactOrganization; - } - - $this->convertAltId($vCardOrgProperty, ["organizations", md5($vCardOrgPropertyValue[0])]); - - // Check if the currently unsupported vCard parameter LANGUAGE is present - // If yes, then provide an error log with some information that it is not supported - if ( - isset($vCardOrgProperty['LANGUAGE']) - && !empty($vCardOrgProperty['LANGUAGE']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered for vCard property ORG" - ); - } - } - - return $jsContactOrganizationsProperty; + return Util::addressToPlace($addr, $this->placeTextAsFullAddress); } /** - * This function maps the JSContact "organizations" property to the vCard ORG property + * Writes a JSContact Address to the vCard BIRTHPLACE property. * - * @param array|null $jsContactOrganizations - * The "organizations" JSContact property as a map of strings to Organization objects + * @param Address $addr */ - public function setOrg($jsContactOrganizations) + protected function setBirthPlaceFromAddress(Address $addr) { - if (!isset($jsContactOrganizations) || empty($jsContactOrganizations)) { - return; + $value = $this->getPlaceValueFromAddress($addr); + if ($value !== null) { + $this->addSingleProperty('BIRTHPLACE', $value); } + } - foreach ($jsContactOrganizations as $id => $jsContactOrganization) { - if (isset($jsContactOrganization) && !empty($jsContactOrganization)) { - $jsContactOrganizationName = $jsContactOrganization->name; - $jsContactOrganizationUnits = $jsContactOrganization->units; - - if (isset($jsContactOrganizationName) && !empty($jsContactOrganizationName)) { - if (isset($jsContactOrganizationUnits) && !empty($jsContactOrganizationUnits)) { - $this->vCard->add( - "ORG", - $jsContactOrganizationName . ';' . implode(';', $jsContactOrganizationUnits) - ); - } else { - $this->vCard->add("ORG", $jsContactOrganizationName); - } - } - } + /** + * Writes a JSContact Address to the vCard DEATHPLACE property. + * + * @param Address $addr + */ + protected function setDeathPlaceFromAddress(Address $addr) + { + $value = $this->getPlaceValueFromAddress($addr); + if ($value !== null) { + $this->addSingleProperty('DEATHPLACE', $value); } } /** - * This function maps the vCard "RELATED" property to the JSContact "relatedTo" property + * This function maps the JSContact "anniversaries" property to the vCard BDAY, BIRTHPLACE, + * DEATHDATE, DEATHPLACE, and ANNIVERSARY properties * - * @return array|null The "relatedTo" JSContact property as a map of UIDs to Relation objects + * @param ContactCard $card */ - public function getRelatedTo() + public function setAnniversaries(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $anns = $card->getAnniversaries(); + if (!is_array($anns) || empty($anns)) { return; } - $jsContactRelatedToProperty = null; - - // RELATED property mapping - if (in_array("RELATED", $this->vCardChildren)) { - $vCardRelatedProperties = $this->vCard->RELATED; + $birth = null; + $death = null; + $wedding = null; - foreach ($vCardRelatedProperties as $vCardRelatedProperty) { - if (isset($vCardRelatedProperty)) { - $vCardRelatedPropertyValue = $vCardRelatedProperty->getValue(); - - if (isset($vCardRelatedPropertyValue) && !empty($vCardRelatedPropertyValue)) { - $jsContactRelation = new Relation(); - $jsContactRelation->setAtType("Relation"); + foreach ($anns as $ann) { + if (!($ann instanceof Anniversary)) { + continue; + } - if (isset($vCardRelatedProperty['TYPE']) && !empty($vCardRelatedProperty)) { - $jsContactRelationMap = []; + $kind = strtolower(trim((string) $ann->getKind())); + $label = strtolower(trim((string) $ann->getLabel())); - foreach ($vCardRelatedProperty['TYPE'] as $paramValue) { - $jsContactRelationMap[$paramValue] = true; - } + if ($kind === 'birth' && $birth === null) { + $birth = $ann; + } elseif ($kind === 'death' && $death === null) { + $death = $ann; + } elseif ( + $wedding === null + && ( + $kind === 'wedding' + || ($kind === 'other' && in_array($label, array('wedding', + 'marriage', 'marriage date', 'anniversary'), true)) + ) + ) { + $wedding = $ann; + } + } - $jsContactRelation->setRelation( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactRelationMap) - ? $jsContactRelationMap - : null - ); - } else { - // According to IETF draft: - // If no relation type given, "relation" property must contain an empty object - $jsContactRelation->setRelation(json_decode("{}")); - } + if ($birth instanceof Anniversary) { + $this->setBirthday($birth->getDate()); + $place = $birth->getPlace(); + if ($place instanceof Address) { + $this->setBirthPlaceFromAddress($place); + } + } - $jsContactRelatedToProperty[$vCardRelatedPropertyValue] = $jsContactRelation; - } - } + if ($death instanceof Anniversary) { + $this->setDeathDate($death->getDate()); + $place = $death->getPlace(); + if ($place instanceof Address) { + $this->setDeathPlaceFromAddress($place); } } - return $jsContactRelatedToProperty; + if ($this->mapVcardAnniversaryToWedding && $wedding instanceof Anniversary) { + $this->setAnniversary($wedding->getDate()); + } } /** - * This function maps the JSContact "relatedTo" property to the vCard RELATED property + * This function maps the vCard "BDAY", "BIRTHPLACE", "DEATHDATE", "DEATHPLACE" and "ANNIVERSARY" properties + * to the JSContact "anniversaries" property * - * @param array|null $jsContactRelatedTo - * The "relatedTo" JSContact property as a map of strings to Relation objects + * @param ContactCard $card */ - public function setRelated($jsContactRelatedTo) + public function getAnniversaries(ContactCard $card) { - if (!isset($jsContactRelatedTo) || empty($jsContactRelatedTo)) { - return; - } + $anns = array(); - foreach ($jsContactRelatedTo as $relatedUid => $jsContactRelation) { - if (isset($relatedUid) && !empty($relatedUid)) { - if (isset($jsContactRelation) && !empty($jsContactRelation)) { - $jsContactRelationValues = $jsContactRelation->relation; - if (isset($jsContactRelationValues) && !empty($jsContactRelationValues)) { - $vCardRelatedParams = []; - foreach ($jsContactRelationValues as $jsContactRelationValue => $booleanValue) { - if (isset($jsContactRelationValue) && !empty($jsContactRelationValue)) { - $vCardRelatedParams['type'][] = $jsContactRelationValue; - } - } + $bday = $this->getBirthday(); + $bplace = $this->getBirthPlaceRaw(); + if ($bday !== '0000-00-00' || $bplace !== null) { + $a = new Anniversary(); + $a->setKind('birth'); + if ($bday !== '0000-00-00') { + $a->setDate($bday); + } + if ($bplace !== null) { + $addr = $this->placeRawToAddress($bplace); + if ($addr instanceof Address) { + $a->setPlace($addr); + } + } + $anns[] = $a; + } - $this->vCard->add("RELATED", $relatedUid, $vCardRelatedParams); - } + $ddate = $this->getDeathDate(); + $dplace = $this->getDeathPlaceRaw(); + if ($ddate !== '0000-00-00' || $dplace !== null) { + $a = new Anniversary(); + $a->setKind('death'); + if ($ddate !== '0000-00-00') { + $a->setDate($ddate); + } + if ($dplace !== null) { + $addr = $this->placeRawToAddress($dplace); + if ($addr instanceof Address) { + $a->setPlace($addr); } } + $anns[] = $a; + } + + if ($this->mapVcardAnniversaryToWedding) { + $anniv = $this->getAnniversary(); + if ($anniv !== '0000-00-00') { + $a = new Anniversary(); + $a->setKind('wedding'); + $a->setLabel('anniversary'); + $a->setDate($anniv); + $anns[] = $a; + } + } + + if (!empty($anns)) { + $card->setAnniversaries($anns); } } /** - * This function maps the vCard "EXPERTISE", "HOBBY" and "INTEREST" properties to - * the JSContact "personalInfo" property + * This function maps the JSContact "relatedTo" property to the vCard RELATED property * - * @return array|null - * The "personalInfo" JSContact property as a map of IDs to PersonalInformation objects + * @param ContactCard $card */ - public function getPersonalInfo() + public function setRelatedTo(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $relatedTo = $card->getRelatedTo(); + if (!is_array($relatedTo) || empty($relatedTo)) { return; } - $jsContactPersonalInfoProperty = null; - - // EXPERTISE property mapping - if (in_array("EXPERTISE", $this->vCardChildren)) { - $vCardExpertiseProperties = $this->vCard->EXPERTISE; - - foreach ($vCardExpertiseProperties as $vCardExpertiseProperty) { - if (isset($vCardExpertiseProperty)) { - $vCardExpertisePropertyValue = $vCardExpertiseProperty->getValue(); - - if (isset($vCardExpertisePropertyValue) && !empty($vCardExpertisePropertyValue)) { - $jsContactExpertiseEntry = new PersonalInformation(); - $jsContactExpertiseEntry->setAtType("PersonalInformation"); - $jsContactExpertiseEntry->setType('expertise'); - $jsContactExpertiseEntry->setValue($vCardExpertisePropertyValue); - - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardExpertiseProperty['LEVEL'])) { - switch ($vCardExpertiseProperty['LEVEL']) { - case 'beginner': - $jsContactExpertiseEntry->setLevel('low'); - break; - - case 'average': - $jsContactExpertiseEntry->setLevel('medium'); - break; - - case 'expert': - $jsContactExpertiseEntry->setLevel('high'); - break; - - default: - $this->logger->warning( - "Unknown vCard LEVEL parameter value encountered - for vCard property EXPERTISE: " . $vCardExpertiseProperty['LEVEL'] - ); - break; - } - } - - // If the INDEX parameter is set for EXPERTISE, then use it as the key for - // the "personalInfo" entry representing the corresponding expertise - // If it's not set, then use a MD5 hash of the expertise's value - if (isset($vCardExpertiseProperty['INDEX']) && !empty($vCardExpertiseProperty['INDEX'])) { - $jsContactPersonalInfoProperty["EXPERTISE-" . $vCardExpertiseProperty['INDEX']] - = $jsContactExpertiseEntry; - } else { - $jsContactPersonalInfoProperty[md5($vCardExpertisePropertyValue)] - = $jsContactExpertiseEntry; - } - } - } + foreach ($relatedTo as $key => $relationObj) { + if ($key === null || $key === '') { + continue; } - } - - // HOBBY property mapping - if (in_array("HOBBY", $this->vCardChildren)) { - $vCardHobbyProperties = $this->vCard->HOBBY; - - foreach ($vCardHobbyProperties as $vCardHobbyProperty) { - if (isset($vCardHobbyProperty)) { - $vCardHobbyPropertyValue = $vCardHobbyProperty->getValue(); - if (isset($vCardHobbyPropertyValue) && !empty($vCardHobbyPropertyValue)) { - $jsContactHobbyEntry = new PersonalInformation(); - $jsContactHobbyEntry->setAtType("PersonalInformation"); - $jsContactHobbyEntry->setType('hobby'); - $jsContactHobbyEntry->setValue($vCardHobbyPropertyValue); + if (!is_object($relationObj)) { + $this->vCard->add('RELATED', $key); + continue; + } - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardHobbyProperty['LEVEL'])) { - $jsContactHobbyEntry->setLevel($vCardHobbyProperty['LEVEL']); - } + $relationMap = $relationObj->getRelation(); + $types = array(); - // If the INDEX parameter is set for HOBBY, then use it as the key for - // the "personalInfo" entry representing the corresponding hobby - // If it's not set, then use a MD5 hash of the hobby's value - if (isset($vCardHobbyProperty['INDEX']) && !empty($vCardHobbyProperty['INDEX'])) { - $jsContactPersonalInfoProperty["HOBBY-" . $vCardHobbyProperty['INDEX']] - = $jsContactHobbyEntry; - } else { - $jsContactPersonalInfoProperty[md5($vCardHobbyPropertyValue)] - = $jsContactHobbyEntry; - } + if (is_array($relationMap)) { + foreach ($relationMap as $type => $flag) { + if ($flag) { + $types[] = $type; } } } - } - - // INTEREST property mapping - if (in_array("INTEREST", $this->vCardChildren)) { - $vCardInterestProperties = $this->vCard->INTEREST; - - foreach ($vCardInterestProperties as $vCardInterestProperty) { - if (isset($vCardInterestProperty)) { - $vCardInterestPropertyValue = $vCardInterestProperty->getValue(); - if (isset($vCardInterestPropertyValue) && !empty($vCardInterestPropertyValue)) { - $jsContactInterestEntry = new PersonalInformation(); - $jsContactInterestEntry->setAtType("PersonalInformation"); - $jsContactInterestEntry->setType('interest'); - $jsContactInterestEntry->setValue($vCardInterestPropertyValue); - - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardInterestProperty['LEVEL'])) { - $jsContactInterestEntry->setLevel($vCardInterestProperty['LEVEL']); - } - - // If the INDEX parameter is set for INTEREST, then use it as the key for - // the "personalInfo" entry representing the corresponding interest - // If it's not set, then use a MD5 hash of the interest's value - if (isset($vCardInterestProperty['INDEX']) && !empty($vCardInterestProperty['INDEX'])) { - $jsContactPersonalInfoProperty["INTEREST-" . $vCardInterestProperty['INDEX']] - = $jsContactInterestEntry; - } else { - $jsContactPersonalInfoProperty[md5($vCardInterestPropertyValue)] - = $jsContactInterestEntry; - } - } - } + if (empty($types)) { + $this->vCard->add('RELATED', $key); + } else { + $this->vCard->add('RELATED', $key, array('TYPE' => $types)); } } - - return $jsContactPersonalInfoProperty; } /** - * This function maps the entries of the JSContact "relatedTo" property corresponding to - * the vCard EXPERTISE property to it + * This function maps the vCard "RELATED" property to the JSContact "relatedTo" property * - * @param array|null $jsContactPersonalInfo - * The "personalInfo" JSContact property as a map of strings to PersonalInformation objects + * @param ContactCard $card */ - public function setExpertise($jsContactPersonalInfo) + public function getRelatedTo(ContactCard $card) { - if (!isset($jsContactPersonalInfo) || empty($jsContactPersonalInfo)) { + $vRelated = $this->vCard->RELATED; + if (!AdapterUtil::isSetAndNotNull($vRelated) || empty($vRelated)) { return; } - foreach ($jsContactPersonalInfo as $id => $jsContactExpertise) { - if (isset($jsContactExpertise) && !empty($jsContactExpertise)) { - $jsContactExpertiseType = $jsContactExpertise->type; - if ( - isset($jsContactExpertiseType) - && !empty($jsContactExpertiseType) - && strcmp($jsContactExpertiseType, "expertise") - ) { - $vCardExpertiseParams = []; - - $jsContactExpertiseValue = $jsContactExpertise->value; - if (isset($jsContactExpertiseValue) && !empty($jsContactExpertiseValue)) { - $jsContactExpertiseLevel = $jsContactExpertise->level; - if (isset($jsContactExpertiseLevel) && !empty($jsContactExpertiseLevel)) { - switch ($jsContactExpertiseLevel) { - case 'low': - $vCardExpertiseParams['level'][] = 'beginner'; - break; - - case 'medium': - $vCardExpertiseParams['level'][] = 'average'; - break; - - case 'high': - $vCardExpertiseParams['level'][] = 'expert'; - break; - - default: - throw new InvalidArgumentException( - "Unknown value encountered for the JSContact - \"level\" property of the JSContact PersonalInformation object - during conversion from the \"personalInfo\" JSContact property to - the EXPERTISE vCard property. Encountered value is:" . $jsContactExpertiseLevel - ); - break; - } - } + $relatedMap = array(); - if (isset($id) && !empty($id) && strpos($id, "-") !== false) { - $vCardExpertiseParams['index'] = explode('-', $id)[1]; - } + foreach ($vRelated as $rel) { + $key = trim((string) $rel); + if ($key === '') { + continue; + } - $this->vCard->add("EXPERTISE", $jsContactExpertiseValue, $vCardExpertiseParams); + $relationTypes = array(); + if (isset($rel['TYPE'])) { + $typeParts = $rel['TYPE']->getParts(); + if (is_array($typeParts)) { + foreach ($typeParts as $type) { + if ($type !== '' && $type !== null) { + $relationTypes[$type] = true; + } } } } + + $relationObj = new Relation(); + $relationObj->setRelation($relationTypes); + $relatedMap[$key] = $relationObj; + } + + if (!empty($relatedMap)) { + $card->setRelatedTo($relatedMap); } } /** - * This function maps the entries of the JSContact "relatedTo" property corresponding to - * the vCard HOBBY property to it + * This function maps the JSContact "members" property to the vCard MEMBER property and sets KIND to "group" * - * @param array|null $jsContactPersonalInfo - * The "personalInfo" JSContact property as a map of strings to PersonalInformation objects + * @param ContactCard $card */ - public function setHobby($jsContactPersonalInfo) + public function setMembers(ContactCard $card) { - if (!isset($jsContactPersonalInfo) || empty($jsContactPersonalInfo)) { + $members = $card->getMembers(); + if (!is_array($members) || empty($members)) { return; } - foreach ($jsContactPersonalInfo as $id => $jsContactHobby) { - if (isset($jsContactHobby) && !empty($jsContactHobby)) { - $jsContactHobbyType = $jsContactHobby->type; - if ( - isset($jsContactHobbyType) - && !empty($jsContactHobbyType) - && strcmp($jsContactHobbyType, "hobby") - ) { - $vCardHobbyParams = []; - - $jsContactHobbyValue = $jsContactHobby->value; - if (isset($jsContactHobbyValue) && !empty($jsContactHobbyValue)) { - $jsContactHobbyLevel = $jsContactHobby->level; - if (isset($jsContactHobbyLevel) && !empty($jsContactHobbyLevel)) { - $vCardHobbyParams['level'][] = $jsContactHobbyLevel; - } + $wroteMember = false; + foreach ($members as $uid => $flag) { + if ($uid === null || $uid === '' || $flag !== true) { + continue; + } - if (isset($id) && !empty($id) && strpos($id, "-") !== false) { - $vCardHobbyParams['index'] = explode('-', $id)[1]; - } + $this->vCard->add('MEMBER', $uid, array('VALUE' => 'uri')); + $wroteMember = true; + } - $this->vCard->add("HOBBY", $jsContactHobbyValue, $vCardHobbyParams); - } - } - } + if ($wroteMember) { + $this->addSingleProperty('KIND', 'group'); } } /** - * This function maps the entries of the JSContact "relatedTo" property corresponding to - * the vCard INTEREST property to it + * This function maps the vCard "MEMBER" property to the JSContact "members" property * - * @param array|null $jsContactPersonalInfo - * The "personalInfo" JSContact property as a map of strings to PersonalInformation objects + * @param ContactCard $card */ - public function setInterest($jsContactPersonalInfo) + public function getMembers(ContactCard $card) { - if (!isset($jsContactPersonalInfo) || empty($jsContactPersonalInfo)) { + if (!is_string($this->rawVCard) || $this->rawVCard === '') { return; } - foreach ($jsContactPersonalInfo as $id => $jsContactInterest) { - if (isset($jsContactInterest) && !empty($jsContactInterest)) { - $jsContactInterestType = $jsContactInterest->type; - if ( - isset($jsContactInterestType) - && !empty($jsContactInterestType) - && strcmp($jsContactInterestType, "interest") - ) { - $vCardInterestParams = []; - - $jsContactInterestValue = $jsContactInterest->value; - if (isset($jsContactInterestValue) && !empty($jsContactInterestValue)) { - $jsContactInterestLevel = $jsContactInterest->level; - if (isset($jsContactInterestLevel) && !empty($jsContactInterestLevel)) { - $vCardInterestParams['level'][] = $jsContactInterestLevel; - } + $members = array(); + $vcf = preg_replace("/\r\n[ \t]/", '', $this->rawVCard); - if (isset($id) && !empty($id) && strpos($id, "-") !== false) { - $vCardInterestParams['index'] = explode('-', $id)[1]; - } - - $this->vCard->add("INTEREST", $jsContactInterestValue, $vCardInterestParams); - } + if (preg_match_all('/^MEMBER(?:;[^:]*)?:(.+)$/im', $vcf, $matches)) { + foreach ($matches[1] as $value) { + $uri = trim($value); + if ($uri === '') { + continue; } + $members[$uri] = true; } } + + if (!empty($members)) { + $card->setMembers($members); + } } /** - * This function maps the vCard "CATEGORIES" property to the JSContact "categories" property + * This function maps the vCard "CATEGORIES" property to the JSContact "keywords" property * - * @return array|null - * The "categories" JSContact property as a map of categories to the boolean value "true" + * @param ContactCard $card */ - public function getCategories() + public function getKeywords(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $vCats = $this->vCard->CATEGORIES; + if (!AdapterUtil::isSetAndNotNull($vCats) || empty($vCats)) { return; } - $jsContactCategoriesProperty = null; - - // CATEGORIES property mapping - if (in_array("CATEGORIES", $this->vCardChildren)) { - $vCardCategoriesProperty = $this->vCard->CATEGORIES; + $keywords = array(); - if (isset($vCardCategoriesProperty)) { - $vCardCategoriesPropertyValue = $vCardCategoriesProperty->getParts(); + foreach ($vCats as $catProp) { + if (!($catProp instanceof Property)) { + continue; + } + $parts = $catProp->getParts(); + if (!is_array($parts) || empty($parts)) { + $val = trim((string) $catProp); + if ($val !== '') { + $keywords[$val] = true; + } + continue; + } - if (isset($vCardCategoriesPropertyValue) && !empty($vCardCategoriesPropertyValue)) { - foreach ($vCardCategoriesPropertyValue as $vCardCategoryValue) { - $jsContactCategoriesProperty[$vCardCategoryValue] = true; - } + foreach ($parts as $p) { + $p = trim((string) $p); + if ($p !== '') { + $keywords[$p] = true; } } } - return $jsContactCategoriesProperty; + if (!empty($keywords)) { + $card->setKeywords($keywords); + } } /** - * This function maps the "categories" JSContact property to the CATEGORIES vCard property + * This function maps the JSContact "keywords" property to the vCard CATEGORIES property * - * @param array|null $jsContactCategories - * The "categories" JSContact property as a map of strings to booleans + * @param ContactCard $card */ - public function setCategories($jsContactCategories) + public function setKeywords(ContactCard $card) { - if (!isset($jsContactCategories) || empty($jsContactCategories)) { + $keywords = $card->getKeywords(); + if (!is_array($keywords) || empty($keywords)) { return; } - $vCardCategoriesValues = []; - - foreach ($jsContactCategories as $jsContactCategory => $booleanValue) { - if (isset($jsContactCategory) && !empty($jsContactCategory)) { - $vCardCategoriesValues[] = $jsContactCategory; + $values = array(); + foreach ($keywords as $kw => $flag) { + if ($flag && is_string($kw) && $kw !== '') { + $values[] = $kw; } } - $this->vCard->add("CATEGORIES", $vCardCategoriesValues); + if (!empty($values)) { + $this->vCard->add('CATEGORIES', $values); + } } /** - * This function maps the vCard "NOTE" property to the JSContact "notes" property + * This function maps the vCard "EXPERTISE", "HOBBY" and "INTEREST" properties to + * the JSContact "personalInfo" property * - * @return string|null The "notes" JSContact property as a string value + * @param ContactCard $card */ - public function getNotes() + public function getPersonalInfo(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } + $info = array(); - $jsContactNotesProperty = null; + $readProps = function ($propName, $kind) use (&$info) { + $props = $this->vCard->{$propName}; + if (!AdapterUtil::isSetAndNotNull($props) || empty($props)) { + return; + } - // NOTE property mapping - if (in_array("NOTE", $this->vCardChildren)) { - $vCardNoteProperties = $this->vCard->NOTE; + foreach ($props as $prop) { + $value = trim((string) $prop); + if ($value === '') { + continue; + } - // If there are multiple NOTE instances, they're condensed into a single note and separated by newline - foreach ($vCardNoteProperties as $i => $vCardNoteProperty) { - if (isset($vCardNoteProperty)) { - $vCardNotePropertyValue = $vCardNoteProperty->getValue(); + $level = null; + if (isset($prop['LEVEL'])) { + $level = Util::mapLevelFromVcard((string) $prop['LEVEL']); + } - if (isset($vCardNotePropertyValue) && !empty($vCardNotePropertyValue)) { - // Check if we're dealing with the last of multiple NOTE property instances - // If yes, then don't append a newline character after it - if (isset($i) && is_int($i)) { - if ($i < count($vCardNoteProperties) - 1) { - $jsContactNotesProperty .= $vCardNotePropertyValue . "\n"; - } else { - $jsContactNotesProperty .= $vCardNotePropertyValue; - } - } - } + $pi = new PersonalInformation($kind, $value); - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if ( - isset($vCardNoteProperty['ALTID']) - && !empty($vCardNoteProperty['ALTID']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered - for vCard property NOTE" - ); - } + if ($level !== null) { + $pi->setLevel($level); + } - if ( - isset($vCardNoteProperty['LANGUAGE']) - && !empty($vCardNoteProperty['LANGUAGE']) - ) { - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered - for vCard property NOTE" - ); + if (isset($prop['INDEX'])) { + $rawIndex = trim((string) $prop['INDEX']); + if ($rawIndex !== '' && ctype_digit($rawIndex)) { + $pi->setListAs((int) $rawIndex); } } + + $info[] = $pi; } - } + }; - return $jsContactNotesProperty; - } + $readProps('EXPERTISE', 'expertise'); + $readProps('HOBBY', 'hobby'); + $readProps('INTEREST', 'interest'); - /** - * This function maps the "notes" JSContact property to the NOTE vCard property - * - * @param string|null $jsContactNotes The "notes" JSContact property as a string - */ - public function setNote($jsContactNotes) - { - if (!isset($jsContactNotes) || empty($jsContactNotes)) { - return; + if (!empty($info)) { + $card->setPersonalInfo($info); } - - $this->vCard->add("NOTE", $jsContactNotes); } /** - * This function maps the vCard "PRODID" property to the JSContact "prodId" property + * This function maps the JSContact "personalInfo" property to the vCard EXPERTISE, HOBBY, or INTEREST properties * - * @return string|null The "prodId" JSContact property as a string value + * @param ContactCard $card */ - public function getProdId() + public function setPersonalInfo(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $info = $card->getPersonalInfo(); + if (!is_array($info) || empty($info)) { return; } - $jsContactProdIdProperty = null; + foreach ($info as $pi) { + if (!($pi instanceof PersonalInformation)) { + continue; + } + + $kind = $pi->getKind(); + $value = $pi->getValue(); + if (!is_string($value) || $value === '') { + continue; + } - // PRODID property mapping - if (in_array("PRODID", $this->vCardChildren)) { - $vCardProdIdProperty = $this->vCard->PRODID; + if ($kind === 'expertise') { + $propName = 'EXPERTISE'; + } elseif ($kind === 'hobby') { + $propName = 'HOBBY'; + } elseif ($kind === 'interest') { + $propName = 'INTEREST'; + } else { + continue; + } - if (isset($vCardProdIdProperty)) { - $vCardProdIdPropertyValue = $vCardProdIdProperty->getValue(); + $params = array(); - if (isset($vCardProdIdPropertyValue) && !empty($vCardProdIdPropertyValue)) { - $jsContactProdIdProperty = $vCardProdIdPropertyValue; + $level = $pi->getLevel(); + if (is_string($level) && $level !== '') { + $mapped = Util::mapLevelToVcard($level); + if ($mapped !== null) { + $params['LEVEL'] = $mapped; } } - } - return $jsContactProdIdProperty; - } + $idx = $pi->getListAs(); + if (is_int($idx) && $idx >= 0) { + $params['INDEX'] = (string) $idx; + } - /** - * This function maps the "prodId" JSContact property to the PRODID vCard property - * - * @param string|null $jsContactProdId The "prodId" JSContact property as a string - */ - public function setProdId($jsContactProdId) - { - if (!isset($jsContactProdId) || empty($jsContactProdId)) { - return; + $this->vCard->add($propName, $value, $params); } - - $this->vCard->add("PRODID", $jsContactProdId); } /** - * This function maps the vCard "REV" property to the JSContact "updated" property + * Build common vCard parameters for URI-based objects. + * + * Extracts mediaType, contexts (TYPE parameter), and pref from objects. + * Used by media, directories, links, crypto keys, and scheduling addresses. * - * @return string|null The "updated" JSContact property as a string value representing a date and time + * @param object $obj The object to extract parameters from + * @param mixed $id The map key/id for PROP-ID parameter + * @return array The vCard parameters */ - public function getUpdated() + protected function buildCommonUriObjectParams($obj, $id = null) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactUpdatedProperty = null; + $params = array(); - // REV property mapping - if (in_array("REV", $this->vCardChildren)) { - $vCardRevProperty = $this->vCard->REV; - - if (isset($vCardRevProperty)) { - $vCardRevPropertyValue = $vCardRevProperty->getValue(); - - if (isset($vCardRevPropertyValue) && !empty($vCardRevPropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactUpdatedProperty = AdapterUtil::parseDateTime( - $vCardRevPropertyValue, - 'Ymd\THis\Z', - 'Y-m-d\TH:i:s\Z' - ); - - if (is_null($jsContactUpdatedProperty)) { - $this->logger->error("Couldn't parse vCard REV property date to JSContact's - \"updated\" property. vCard date encountered is: " . $vCardRevPropertyValue); - return; - } - } + // MediaType + if (method_exists($obj, 'getMediaType')) { + $mt = $obj->getMediaType(); + if (is_string($mt) && $mt !== '') { + $params['MEDIATYPE'] = $mt; } } - return $jsContactUpdatedProperty; - } - - /** - * This function maps the "updated" JSContact property to the REV vCard property - * - * @param string|null $jsContactUpdated The "updated" JSContact property as a datetime string - */ - public function setRev($jsContactUpdated) - { - if (!isset($jsContactUpdated) || empty($jsContactUpdated)) { - return; + // Contexts (TYPE parameter) + $types = Util::contextsToVcardTypeParam($obj); + if (!empty($types)) { + $params['TYPE'] = $types; } - $vCardRevValue = AdapterUtil::parseDateTime( - $jsContactUpdated, - 'Y-m-d\TH:i:s\Z', - 'Ymd\THis\Z' - ); + // Preference + $pref = Util::prefToVcardParam($obj); + if ($pref !== null) { + $params['PREF'] = $pref; + } - if (is_null($vCardRevValue)) { - throw new InvalidArgumentException( - "Date parsing failed during conversion from JSContact's - \"updated\" property to vCard's REV property. Encountered date value that - was tried for parsing is: " . $jsContactUpdated - ); - return; + // PROP-ID + if ($id !== null) { + $params = Util::addPropIdParam($params, $id); } - $this->vCard->add("REV", $vCardRevValue); + return $params; } /** - * This function maps the vCard "UID" property to the JSContact "uid" property + * Preserve selected vCard parameters (ALTID, LANGUAGE) on the ContactCard + * so they can be restored on export. + * + * Stored in properties['vCardParams'][propName][mapKey]. * - * @return string|null The "uid" JSContact property as a string value + * @param ContactCard $card The contact card + * @param string $propName vCard property name (e.g., 'NICKNAME', 'NOTE') + * @param string $mapKey Map key/ID for the JSContact object + * @param Property $prop vCard property object */ - public function getUid() + protected function preserveVcardParams(ContactCard $card, $propName, $mapKey, $prop) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; + $params = array(); + + if (isset($prop['ALTID'])) { + $altId = trim((string) $prop['ALTID']); + if ($altId !== '') { + $params['ALTID'] = $altId; + } } - $jsContactUidProperty = null; + if (isset($prop['LANGUAGE'])) { + $language = trim((string) $prop['LANGUAGE']); + if ($language !== '') { + $params['LANGUAGE'] = $language; + } + } - // UID property mapping - if (in_array("UID", $this->vCardChildren)) { - $vCardUidProperty = $this->vCard->UID; + if (empty($params) || $mapKey === null || $mapKey === '') { + return; + } - if (isset($vCardUidProperty)) { - $vCardUidPropertyValue = $vCardUidProperty->getValue(); + $all = $card->getProperty('vCardParams'); + if (!is_array($all)) { + $all = array(); + } - if (isset($vCardUidPropertyValue) && !empty($vCardUidPropertyValue)) { - $jsContactUidProperty = $vCardUidPropertyValue; - } - } + if (!isset($all[$propName]) || !is_array($all[$propName])) { + $all[$propName] = array(); } - return $jsContactUidProperty; + $all[$propName][$mapKey] = $params; + $card->setProperty('vCardParams', $all); } /** - * This function maps the "uid" JSContact property to the UID vCard property + * Restore previously preserved vCard parameters from ContactCard::properties. * - * @param string|null $jsContactUid The "uid" JSContact property as a string + * @param ContactCard $card The contact card + * @param string $propName vCard property name + * @param string $mapKey Map key/ID for the JSContact object + * @param array $params Existing parameters to merge with + * @return array Merged parameters with preserved values */ - public function setUid($jsContactUid) + protected function restoreVcardParams(ContactCard $card, $propName, $mapKey, array $params = array()) { - if (!isset($jsContactUid) || empty($jsContactUid)) { - return; + $all = $card->getProperty('vCardParams'); + if (!is_array($all)) { + return $params; } - $this->vCard->add("UID", $jsContactUid); - } - - /** - * Basically does - * https://www.ietf.org/archive/id/draft-ietf-calext-jscontact-vcard-06.html#section-3.1-2.2 - */ - public function deriveFN($jsContactName) - { - if ( - isset($this->vCard->FN) && - null != $this->vCard->FN->getValue() && - !empty($this->vCard->FN->getValue()) - ) { - return; + if (!isset($all[$propName]) || !is_array($all[$propName])) { + return $params; } - $this->logger->info("FN was not set. Trying to derive FN from N."); - - $nameStr = implode(" ", JSContactVCardAdapterUtil::convertFromNameToN($jsContactName)); + if (!isset($all[$propName][$mapKey]) || !is_array($all[$propName][$mapKey])) { + return $params; + } - if (!strlen($nameStr)) { - $this->logger->warning("N components were empty and no FN was set. This is weird, because contacts " . - "usually have some name. Falling back to empty string."); - $this->vCard->add("FN", ""); + foreach ($all[$propName][$mapKey] as $name => $value) { + if (!isset($params[$name]) && is_string($value) && $value !== '') { + $params[$name] = $value; + } } - $this->vCard->add("FN", $nameStr); - $this->vCard->FN["DERIVED"] = true; + return $params; } } diff --git a/src/adapter/NextcloudJSContactVCardAdapter.php b/src/adapter/NextcloudJSContactVCardAdapter.php index 8a435d5..328a355 100644 --- a/src/adapter/NextcloudJSContactVCardAdapter.php +++ b/src/adapter/NextcloudJSContactVCardAdapter.php @@ -2,12 +2,14 @@ namespace OpenXPort\Adapter; +use OpenXPort\Jmap\JSContact\ContactCard; use OpenXPort\Jmap\JSContact\OnlineService; -use OpenXPort\Util\JSContactVCardAdapterUtil; +use OpenXPort\Util\AdapterUtil; +use OpenXPort\Util\JSContactVCardAdapterUtil as Util; /** * Nextcloud-specific adapter to convert between vCard <-> JSContact. - * Overrides methods of the generic adapter if Roundcube deviates. + * Overrides methods of the generic adapter if Nextcloud deviates. */ class NextcloudJSContactVCardAdapter extends JSContactVCardAdapter { @@ -18,54 +20,165 @@ class NextcloudJSContactVCardAdapter extends JSContactVCardAdapter * * Overrides getOnlineServices from parent */ - public function getOnlineServices() + public function getOnlineServices(ContactCard $card) { - $jsContactOnlineProperty = parent::getOnlineServices(); + // First call parent to get standard properties + parent::getOnlineServices($card); - $socialProps = []; + // Get existing services or initialize empty array + $jsContactOnlineProperty = $card->getOnlineServices(); + if (!is_array($jsContactOnlineProperty)) { + $jsContactOnlineProperty = array(); + } + // Check if X-SOCIALPROFILE exists + $socialProps = array(); if (is_null($this->vCard->__get("X-SOCIALPROFILE"))) { - return $jsContactOnlineProperty; + return; } + foreach ($this->vCard->__get("X-SOCIALPROFILE") as $vCardProp) { if (isset($vCardProp)) { array_push($socialProps, $vCardProp); } } + // Calculate starting index for new services + $index = count($jsContactOnlineProperty) + 1; + // This is basically the same as "SOCIALPROFILE" in parent but for X-SOCIALPROFILE. foreach ($socialProps as $vCardSocialProperty) { $vCardSocialPropertyValue = $vCardSocialProperty->getValue(); if (isset($vCardSocialPropertyValue) && !empty($vCardSocialPropertyValue)) { + $jsContactSocialEntry = new OnlineService(); + + // Determine if it's username or uri based on VALUE parameter if ( isset($vCardSocialProperty['VALUE']) && !empty($vCardSocialProperty['VALUE']) && $vCardSocialProperty['VALUE'] == "text" ) { - $jsContactSocialEntry = new OnlineService($vCardSocialPropertyValue, "username"); + $jsContactSocialEntry->setUser($vCardSocialPropertyValue); } else { - $jsContactSocialEntry = new OnlineService($vCardSocialPropertyValue, "uri"); + // Check if it's a URL or just username + if (filter_var($vCardSocialPropertyValue, FILTER_VALIDATE_URL)) { + $jsContactSocialEntry->setUri($vCardSocialPropertyValue); + } else { + $jsContactSocialEntry->setUser($vCardSocialPropertyValue); + } } + // Set preference if present if (isset($vCardSocialProperty['PREF']) && !empty($vCardSocialProperty['PREF'])) { $jsContactSocialEntry->setPref($vCardSocialProperty['PREF']); } + // Set service type if present if (isset($vCardSocialProperty['SERVICE-TYPE']) && !empty($vCardSocialProperty['SERVICE-TYPE'])) { $jsContactSocialEntry->setService($vCardSocialProperty['SERVICE-TYPE']); } - $jsContactSocialEntry->setContexts( - JSContactVCardAdapterUtil::convertFromVCardType($vCardSocialProperty) - ); + // Extract service type from TYPE parameter as well (e.g., TYPE=twitter) + if (empty($jsContactSocialEntry->getService()) && isset($vCardSocialProperty['TYPE'])) { + $types = $vCardSocialProperty['TYPE']->getParts(); + if (is_array($types) && !empty($types)) { + $serviceType = strtolower(trim($types[0])); + // Only set if it looks like a service name (not work/home) + if (!in_array($serviceType, array('work', 'home', 'other'))) { + $jsContactSocialEntry->setService($serviceType); + } + } + } + + // Convert contexts from TYPE parameter + $contexts = Util::vCardTypeParamToContexts($vCardSocialProperty); + if (!empty($contexts)) { + $jsContactSocialEntry->setContexts($contexts); + } + + // Set label to indicate this came from X-SOCIALPROFILE + $jsContactSocialEntry->setLabel('X-SOCIALPROFILE'); // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the IMPP property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardSocialPropertyValue)] = $jsContactSocialEntry; + // the getMapKeyFromPropValue utility or MD5 hash fallback + $key = Util::getMapKeyFromPropValue( + $vCardSocialProperty, + $vCardSocialPropertyValue, + 'os', + $index, + $jsContactOnlineProperty + ); + $jsContactOnlineProperty[$key] = $jsContactSocialEntry; + $index++; } } - return $jsContactOnlineProperty; + // Update the card with all online services + if (!empty($jsContactOnlineProperty)) { + $card->setOnlineServices($jsContactOnlineProperty); + } + } + + /** + * This function maps the JSContact "onlineServices" property to vCard properties including + * Nextcloud-specific X-SOCIALPROFILE for entries explicitly marked as such + * + * @param ContactCard $card The ContactCard containing online services + */ + public function setOnlineServices(ContactCard $card) + { + // First call parent to handle standard properties + parent::setOnlineServices($card); + + $services = $card->getOnlineServices(); + if (!is_array($services) || empty($services)) { + return; + } + + foreach ($services as $service) { + if (!($service instanceof OnlineService)) { + continue; + } + + $label = $service->getLabel(); + if (!is_string($label) || strtoupper(trim($label)) !== 'X-SOCIALPROFILE') { + continue; + } + + $value = Util::getOnlineServiceExportValue($service); + if ($value === null || $value === '') { + continue; + } + + $params = array(); + + $serviceType = $service->getService(); + if (is_string($serviceType) && $serviceType !== '') { + $params['SERVICE-TYPE'] = $serviceType; + } + + $types = Util::contextsToVcardTypeParam($service); + if (!empty($types)) { + $params['TYPE'] = $types; + } + + $pref = Util::prefToVcardParam($service); + if ($pref !== null) { + $params['PREF'] = $pref; + } + + $user = $service->getUser(); + $uri = $service->getUri(); + + if ( + is_string($user) && $user !== '' + && (!is_string($uri) || $uri === '') + ) { + $params['VALUE'] = 'text'; + } + + $this->vCard->add('X-SOCIALPROFILE', $value, $params); + } } } diff --git a/src/adapter/RoundcubeJSContactVCardAdapter.php b/src/adapter/RoundcubeJSContactVCardAdapter.php index aba4b45..ee7768c 100644 --- a/src/adapter/RoundcubeJSContactVCardAdapter.php +++ b/src/adapter/RoundcubeJSContactVCardAdapter.php @@ -2,16 +2,14 @@ namespace OpenXPort\Adapter; -use InvalidArgumentException; -use OpenXPort\Jmap\JSContact\Address; use OpenXPort\Jmap\JSContact\Anniversary; -use OpenXPort\Jmap\JSContact\Organization; -use OpenXPort\Jmap\JSContact\Phone; +use OpenXPort\Jmap\JSContact\OnlineService; use OpenXPort\Jmap\JSContact\Relation; -use OpenXPort\Jmap\JSContact\Resource; +use OpenXPort\Jmap\JSContact\Organization; +use OpenXPort\Util\JSContactVCardAdapterUtil as Util; use OpenXPort\Jmap\JSContact\SpeakToAs; +use OpenXPort\Jmap\JSContact\ContactCard; use OpenXPort\Util\AdapterUtil; -use OpenXPort\Util\Logger; /** * Roundcube-specific adapter to convert between vCard <-> JSContact. @@ -19,555 +17,83 @@ */ class RoundcubeJSContactVCardAdapter extends JSContactVCardAdapter { + protected $logger; + /** * This function maps the vCard "BDAY", "BIRTHPLACE", "DEATHDATE", "DEATHPLACE", "ANNIVERSARY" * and "X-ANNIVERSARY" properties to the JSContact "anniversaries" property * - * @return array|null The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects + * @param ContactCard $card The ContactCard to populate */ - // TODO: Should we format anniversaries to contain only date or date with time as well? - // Currently it's without time - public function getAnniversaries() + public function getAnniversaries(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + // Call parent first to handle standard properties + parent::getAnniversaries($card); + + // Get X-ANNIVERSARY property + $xAnniversary = $this->vCard->__get("X-ANNIVERSARY"); + if (!AdapterUtil::isSetAndNotNull($xAnniversary)) { return; } - $jsContactAnniversariesProperty = null; - - // BDAY property mapping - if (in_array("BDAY", $this->vCardChildren)) { - $vCardBirthdayProperty = $this->vCard->BDAY; - - if (isset($vCardBirthdayProperty)) { - $vCardBirthdayPropertyValue = $vCardBirthdayProperty->getValue(); - - // Only if the vCard BDAY property indeed has a value, we transform it as a date string to - // follow JSContact's date format and set it as value for "date" in an Anniversary object - // which in turn is an element of "anniversaries" in JSContact - if (isset($vCardBirthdayPropertyValue) && !empty($vCardBirthdayPropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactBirthdayPropertyValue = AdapterUtil::parseDateTime( - $vCardBirthdayPropertyValue, - 'Y-m-d', - 'Y-m-d' - ); - - // In case we couldn't parse the BDAY value to JSContact's date format (i.e., it's null), - // set the JSContact value to all zeros (default value) - if (is_null($jsContactBirthdayPropertyValue)) { - $jsContactBirthdayPropertyValue = "0000-00-00"; - } - - $jsContactBirthday = new Anniversary(); - $jsContactBirthday->setAtType("Anniversary"); - $jsContactBirthday->setType("birth"); - $jsContactBirthday->setDate($jsContactBirthdayPropertyValue); - - // In case BIRTHPLACE is present in the vCard, set it as "place" within the JSContact - // birthday Anniversary object - if (in_array("BIRTHPLACE", $this->vCardChildren)) { - $vCardBirthdayPlaceProperty = $this->vCard->BIRTHPLACE; - - if (isset($vCardBirthdayPlaceProperty)) { - $vCardBirthdayPlacePropertyValue = $vCardBirthdayPlaceProperty->getValue(); - - if (isset($vCardBirthdayPlacePropertyValue) && !empty($vCardBirthdayPlacePropertyValue)) { - $jsContactBirthdayPlace = new Address(); - $jsContactBirthdayPlace->setAtType("Address"); - - // If place is geo URL, then add it to "coordinates" prop of address, - // else add it to "fullAddress" - if (str_starts_with($vCardBirthdayPlacePropertyValue, "geo:")) { - $jsContactBirthdayPlace->setCoordinates($vCardBirthdayPlacePropertyValue); - } else { - $jsContactBirthdayPlace->setFullAddress($vCardBirthdayPlacePropertyValue); - } - - $jsContactBirthday->setPlace($jsContactBirthdayPlace); - } - - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if ( - isset($vCardBirthdayPlaceProperty['ALTID']) - && !empty($vCardBirthdayPlaceProperty['ALTID']) - ) { - $this->logger = Logger::getInstance(); - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered - for vCard property BIRTHPLACE" - ); - } - - if ( - isset($vCardBirthdayPlaceProperty['LANGUAGE']) - && !empty($vCardBirthdayPlaceProperty['LANGUAGE']) - ) { - $this->logger = Logger::getInstance(); - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered - for vCard property BIRTHPLACE" - ); - } - } - } - - // Since "anniversaries" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the BDAY property's value to create the key of the entry in "anniversaries" - $jsContactAnniversariesProperty[md5($vCardBirthdayPropertyValue)] = $jsContactBirthday; - } - } + // Get existing anniversaries or initialize + $jsContactAnniversariesProperty = $card->getAnniversaries(); + if (!is_array($jsContactAnniversariesProperty)) { + $jsContactAnniversariesProperty = array(); } - // DEATHDATE property mapping - if (in_array("DEATHDATE", $this->vCardChildren)) { - $vCardDeathdateProperty = $this->vCard->DEATHDATE; - - if (isset($vCardDeathdateProperty)) { - $vCardDeathdatePropertyValue = $vCardDeathdateProperty->getValue(); - - // Only if the vCard DEATHDATE property indeed has a value, we transform it as a date string to - // follow JSContact's date format and set it as value for "date" in an Anniversary object - // which in turn is an element of "anniversaries" in JSContact - if (isset($vCardDeathdatePropertyValue) && !empty($vCardDeathdatePropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactDeathdatePropertyValue = AdapterUtil::parseDateTime( - $vCardDeathdatePropertyValue, - 'Y-m-d', - 'Y-m-d' - ); - - // In case we couldn't parse the DEATHDATE value to JSContact's date format (i.e., it's null), - // set the JSContact value to all zeros (default value) - if (is_null($jsContactDeathdatePropertyValue)) { - $jsContactDeathdatePropertyValue = "0000-00-00"; - } - - $jsContactDeathdate = new Anniversary(); - $jsContactDeathdate->setAtType("Anniversary"); - $jsContactDeathdate->setType("death"); - $jsContactDeathdate->setDate($jsContactDeathdatePropertyValue); - - // In case DEATHPLACE is present in the vCard, set it as "place" within the JSContact - // deathdate Anniversary object - if (in_array("DEATHPLACE", $this->vCardChildren)) { - $vCardDeathdatePlaceProperty = $this->vCard->DEATHPLACE; - - if (isset($vCardDeathdatePlaceProperty)) { - $vCardDeathdatePlacePropertyValue = $vCardDeathdatePlaceProperty->getValue(); - - if (isset($vCardDeathdatePlacePropertyValue) && !empty($vCardDeathdatePlacePropertyValue)) { - $jsContactDeathdatePlace = new Address(); - $jsContactDeathdatePlace->setAtType("Address"); - - // If place is geo URL, then add it to "coordinates" prop of address, - // else add it to "fullAddress" - if (str_starts_with($vCardDeathdatePlacePropertyValue, "geo:")) { - $jsContactDeathdatePlace->setCoordinates($vCardDeathdatePlacePropertyValue); - } else { - $jsContactDeathdatePlace->setFullAddress($vCardDeathdatePlacePropertyValue); - } - - $jsContactDeathdate->setPlace($jsContactDeathdatePlace); - } - - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if ( - isset($vCardDeathdatePlaceProperty['ALTID']) - && !empty($vCardDeathdatePlaceProperty['ALTID']) - ) { - $this->logger = Logger::getInstance(); - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered - for vCard property DEATHPLACE" - ); - } - - if ( - isset($vCardDeathdatePlaceProperty['LANGUAGE']) - && !empty($vCardDeathdatePlaceProperty['LANGUAGE']) - ) { - $this->logger = Logger::getInstance(); - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered - for vCard property DEATHPLACE" - ); - } - } - } - - // Since "anniversaries" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the DEATHDATE property's value to create the key of the entry in "anniversaries" - $jsContactAnniversariesProperty[md5($vCardDeathdatePropertyValue)] = $jsContactDeathdate; - } + foreach ($xAnniversary as $prop) { + $value = trim((string) $prop); + if ($value === "") { + continue; } - } - - // ANNIVERSARY property mapping - if (in_array("ANNIVERSARY", $this->vCardChildren)) { - $vCardAnniversaryProperty = $this->vCard->ANNIVERSARY; - - if (isset($vCardAnniversaryProperty)) { - $vCardAnniversaryPropertyValue = $vCardAnniversaryProperty->getValue(); - - // Only if the vCard ANNIVERSARY property indeed has a value, we transform it as a date string to - // follow JSContact's date format and set it as value for "date" in an Anniversary object - // which in turn is an element of "anniversaries" in JSContact - if (isset($vCardAnniversaryPropertyValue) && !empty($vCardAnniversaryPropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactAnniversaryPropertyValue = AdapterUtil::parseDateTime( - $vCardAnniversaryPropertyValue, - 'Y-m-d', - 'Y-m-d' - ); - - // In case we couldn't parse the ANNIVERSARY value to JSContact's date format (i.e., it's null), - // set the JSContact value to all zeros (default value) - if (is_null($jsContactAnniversaryPropertyValue)) { - $jsContactAnniversaryPropertyValue = "0000-00-00"; - } - - $jsContactAnniversary = new Anniversary(); - $jsContactAnniversary->setAtType("Anniversary"); - - // For ANNIVERSARY, we're supposed to set the corresponding JSContact Anniversary object's "label" - // to some meaningful value. In this case, we just use the value of "anniversary", since we don't - // have any further specifics about what to include in the value. - // Note: "type" of the JSContact Anniversary object is not set here. - $jsContactAnniversary->setLabel("anniversary"); - $jsContactAnniversary->setDate($jsContactAnniversaryPropertyValue); - // Since "anniversaries" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the ANNIVERSARY property value to create the key of the entry in "anniversaries" - $jsContactAnniversariesProperty[md5($vCardAnniversaryPropertyValue)] = $jsContactAnniversary; - } + // Parse the date value + $date = AdapterUtil::parseDateTime($value, 'Y-m-d', 'Y-m-d', 'Ymd'); + if ($date === null) { + continue; } - } - - // X-ANNIVERSARY property mapping - // Note: The X-ANNIVERSARY vCard property is Roundcube-specific - if (in_array("X-ANNIVERSARY", $this->vCardChildren)) { - $vCardXAnniversaryProperty = $this->vCard->__get("X-ANNIVERSARY"); - - if (isset($vCardXAnniversaryProperty)) { - $vCardXAnniversaryPropertyValue = $vCardXAnniversaryProperty->getValue(); - - // Only if the vCard X-ANNIVERSARY property indeed has a value, we transform it as a date string to - // follow JSContact's date format and set it as value for "date" in an Anniversary object - // which in turn is an element of "anniversaries" in JSContact - if (isset($vCardXAnniversaryPropertyValue) && !empty($vCardXAnniversaryPropertyValue)) { - // Restructure the date string value to follow JSContact's format - $jsContactXAnniversaryPropertyValue = AdapterUtil::parseDateTime( - $vCardXAnniversaryPropertyValue, - 'Y-m-d', - 'Y-m-d' - ); - - // In case we couldn't parse the X-ANNIVERSARY value to JSContact's date format (i.e., it's null), - // set the JSContact value to all zeros (default value) - if (is_null($jsContactXAnniversaryPropertyValue)) { - $jsContactXAnniversaryPropertyValue = "0000-00-00"; - } - $jsContactXAnniversary = new Anniversary(); - $jsContactXAnniversary->setAtType("Anniversary"); - - // For X-ANNIVERSARY, we're supposed to set the corresponding JSContact Anniversary object's "label" - // to some meaningful value. In this case, we just use the value of "x-anniversary", since we don't - // have any further specifics about what to include in the value. - // Note: "type" of the JSContact Anniversary object is not set here. - // TODO: Most probably we shouldn't enforce "label" to be always set - $jsContactXAnniversary->setLabel("x-anniversary"); - $jsContactXAnniversary->setDate($jsContactXAnniversaryPropertyValue); - - // Since "anniversaries" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the X-ANNIVERSARY property value - // to create the key of the entry in "anniversaries" - $jsContactAnniversariesProperty[md5($vCardXAnniversaryPropertyValue)] = $jsContactXAnniversary; + // Check if this anniversary already exists + $alreadyExists = false; + foreach ($jsContactAnniversariesProperty as $existing) { + if (!($existing instanceof Anniversary)) { + continue; } - } - } - - return $jsContactAnniversariesProperty; - } - /** - * This function maps entries of the JSContact "anniversaries" property corresponding to - * the vCard X-ANNIVERSARY property to it - * Note: The vCard X-ANNIVERSARY property is Roundcube-specific - * - * @param array|null $jsContactAnniversaries The "anniversaries" JSContact property as a map - * of IDs to Anniversary objects - */ - public function setXAnniversary($jsContactAnniversaries) - { - if (!isset($jsContactAnniversaries) || empty($jsContactAnniversaries)) { - return; - } - - foreach ($jsContactAnniversaries as $id => $jsContactAnniversary) { - if (isset($jsContactAnniversary) && !empty($jsContactAnniversary)) { - $jsContactAnniversaryType = $jsContactAnniversary->type; - $jsContactAnniversaryValue = $jsContactAnniversary->date; - - if (!isset($jsContactAnniversaryType)) { - if (isset($jsContactAnniversaryValue) && !empty($jsContactAnniversaryValue)) { - $vCardXAnniversaryValue = AdapterUtil::parseDateTime( - $jsContactAnniversaryValue, - 'Y-m-d\TH:i:s\Z', - 'Y-m-d', - 'Y-m-d' - ); - - if (is_null($vCardXAnniversaryValue)) { - throw new InvalidArgumentException( - "Couldn't parse JSContact anniversary date to vCard X-ANNIVERSARY. - JSContact date encountered is: " . $jsContactAnniversaryValue - ); - return; - } - - $this->vCard->add("X-ANNIVERSARY", $vCardXAnniversaryValue); - } + if ( + strtolower((string) $existing->getLabel()) === 'x-anniversary' + && $existing->getDate() === $date + ) { + $alreadyExists = true; + break; } } - } - } - - /** - * This function maps the vCard "TEL" property to the JSContact "phones" property - * - * @return array|null The "addresses" JSContact property as a map of IDs to Phone objects - */ - public function getPhones() - { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactPhonesProperty = null; - - // TEL property mapping - if (in_array("TEL", $this->vCardChildren)) { - $vCardPhoneProperties = $this->vCard->TEL; - - foreach ($vCardPhoneProperties as $vCardPhoneProperty) { - if (isset($vCardPhoneProperty)) { - $vCardPhonePropertyValue = $vCardPhoneProperty->getValue(); - - if (isset($vCardPhonePropertyValue) && !empty($vCardPhonePropertyValue)) { - $jsContactPhone = new Phone(); - $jsContactPhone->setAtType("Phone"); - $jsContactPhone->setPhone($vCardPhonePropertyValue); - - // Map the TYPE parameter to "contexts" or "features" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardPhoneProperty['TYPE'])) { - $jsContactPhoneContexts = []; - $jsContactPhoneFeatures = []; - $jsContactPhoneLabels = []; - - // The TYPE parameter can have multiple values and hence be an array. That's why we iterate - // over its values below for conversion purposes - foreach ($vCardPhoneProperty['TYPE'] as $paramValue) { - // The 'home' and 'work' TYPE values are put into the "contexts" property - // The rest of the phone-related values are put into the "features" property - // Finally, anything else (i.e., unknown values) is put into the "labels" property - switch ($paramValue) { - case 'home': - case 'home2': - $jsContactPhoneContexts['private'] = true; - break; - - case 'work': - case 'work2': - $jsContactPhoneContexts['work'] = true; - break; - - case 'other': - $jsContactPhoneContexts = null; - break; - - case 'text': - $jsContactPhoneFeatures['text'] = true; - break; - - case 'voice': - $jsContactPhoneFeatures['voice'] = true; - break; - - case 'fax': - case 'homefax': - case 'workfax': - $jsContactPhoneFeatures['fax'] = true; - break; - - case 'cell': - case 'CELL': - $jsContactPhoneFeatures['cell'] = true; - break; - - case 'video': - $jsContactPhoneFeatures['video'] = true; - break; - - case 'pager': - $jsContactPhoneFeatures['pager'] = true; - break; - - case 'textphone': - $jsContactPhoneFeatures['textphone'] = true; - break; - - default: - $jsContactPhoneLabels[] = $paramValue; - break; - } - } - - $jsContactPhone->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactPhoneContexts) - ? $jsContactPhoneContexts - : null - ); - - $jsContactPhone->setFeatures( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactPhoneFeatures) - ? $jsContactPhoneFeatures - : null - ); - - $jsContactPhone->setLabel( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactPhoneLabels) - ? implode(",", $jsContactPhoneLabels) - : null - ); - } - - // Map the PREF parameter to "pref" - if (AdapterUtil::isSetNotNullAndNotEmpty($vCardPhoneProperty['PREF'])) { - $jsContactPhone->setPref($vCardPhoneProperty['PREF']); - } - } - // Since "phones" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the vCard TEL's value as the key for the JSContact Phone - // object that corresponds to this key in "phones" - $jsContactPhonesProperty[md5($vCardPhonePropertyValue)] = $jsContactPhone; - } + if ($alreadyExists) { + continue; } - } - return $jsContactPhonesProperty; - } + // Create new Anniversary object + $jsContactXAnniversary = new Anniversary(); + $jsContactXAnniversary->setKind('other'); + $jsContactXAnniversary->setLabel('x-anniversary'); + $jsContactXAnniversary->setDate($date); - // TODO: Everywhere here setLabel() needs to be replaced by setType() - // by following the specs here: - // https://datatracker.ietf.org/doc/html/draft-ietf-calext-jscontact-02#section-2.3.3 - // and here: - // https://datatracker.ietf.org/doc/html/draft-ietf-calext-jscontact-vcard-01#section-3.5.2 - // This TODO also affects all setter methods in this adapter that map JSContact's "online" - // property to various vCard properties (hint: look at this method's docstring to - // find the affected vCard property names) - /** - * This function maps the JSContact "phones" property to the vCard TEL property - * - * @param array|null $jsContactPhones - * The "phones" JSContact property as a map of strings to Phone objects - */ - public function setTel($jsContactPhones) - { - if (!isset($jsContactPhones) || empty($jsContactPhones)) { - return; + $jsContactAnniversariesProperty[] = $jsContactXAnniversary; } - foreach ($jsContactPhones as $id => $jsContactPhone) { - if (isset($jsContactPhone) && !empty($jsContactPhone)) { - $jsContactPhoneValue = $jsContactPhone->phone; - $jsContactPhoneContexts = $jsContactPhone->contexts; - $jsContactPhoneFeatures = $jsContactPhone->features; - $jsContactPhoneLabels = $jsContactPhone->label; - $jsContactPhonePref = $jsContactPhone->pref; - - $vCardTelParams = []; - - if (isset($jsContactPhoneValue) && !empty($jsContactPhoneValue)) { - if (isset($jsContactPhoneContexts) && !empty($jsContactPhoneContexts)) { - foreach ($jsContactPhoneContexts as $jsContactPhoneContext => $booleanValue) { - switch ($jsContactPhoneContext) { - case 'private': - $vCardTelParams['type'][] = 'home'; - break; - - case 'work': - $vCardTelParams['type'][] = 'work'; - break; - - // In the case of Roundcube, we don't set 'other' as value for 'type' when - // 'contexts' is null, but rather only when 'other' is set as value in 'contexts' - case 'other': - $vCardTelParams['type'][] = 'other'; - break; - - default: - throw new InvalidArgumentException( - "Unknown value encountered for the \"contexts\" - JSContact property of a JSContact Phone object during conversion from - JSContact's \"phones\" property to vCard's TEL property. - Encountered value is: " . $jsContactPhoneContext - ); - break; - } - } - } - - if (isset($jsContactPhoneFeatures) && !empty($jsContactPhoneFeatures)) { - foreach ($jsContactPhoneFeatures as $jsContactPhoneFeature => $booleanValue) { - $vCardTelParams['type'][] = $jsContactPhoneFeature; - } - } - - if (isset($jsContactPhoneLabels) && !empty($jsContactPhoneLabels)) { - // Since $jsContactPhoneLabels is a string that contains one or more values separated - // by a comma, we need to turn it into an array by calling explode() with comma as delimiter - $jsContactPhoneLabels = explode(',', $jsContactPhoneLabels); - - foreach ($jsContactPhoneLabels as $jsContactPhoneLabel) { - $vCardTelParams['type'][] = $jsContactPhoneLabel; - } - } - - if (isset($jsContactPhonePref)) { - $vCardTelParams['pref'] = $jsContactPhonePref; - } - - $this->vCard->add("TEL", $jsContactPhoneValue, $vCardTelParams); - } - } + // Update the card with all anniversaries + if (!empty($jsContactAnniversariesProperty)) { + $card->setAnniversaries($jsContactAnniversariesProperty); } } /** - * This function translates all necessary vCard properties to the JSContact "online" property + * This function maps the vCard "IMPP", "SOCIALPROFILE", "URL" and Roundcube-specific + * instant messaging properties to the JSContact "onlineServices" property * - * The vCard properties that map to "online" are: - * * SOURCE - * * IMPP - * * LOGO - * * CONTACT-URI - * * ORG-DIRECTORY - * * SOUND - * * URL - * * KEY - * * FBURL - * * CALADRURI - * * CALURI + * The Roundcube-specific vCard properties are: * * X-AIM * * X-ICQ * * X-MSN @@ -575,1901 +101,498 @@ public function setTel($jsContactPhones) * * X-JABBER * * X-SKYPE-USERNAME * - * @return array|null The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card The ContactCard to populate */ - public function getOnline() + public function getOnlineServices(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactOnlineProperty = null; - - // SOURCE property mapping - if (in_array("SOURCE", $this->vCardChildren)) { - $vCardSourceProperties = $this->vCard->SOURCE; - - foreach ($vCardSourceProperties as $vCardSourceProperty) { - if (isset($vCardSourceProperty)) { - $vCardSourcePropertyValue = $vCardSourceProperty->getValue(); - - // Only if the vCard SOURCE property indeed has a value, create a - // corresponding entry in the JSContact "online" property - if (isset($vCardSourcePropertyValue) && !empty($vCardSourcePropertyValue)) { - $jsContactSourceEntry = new Resource(); - $jsContactSourceEntry->setAtType("Resource"); - $jsContactSourceEntry->setType('uri'); - $jsContactSourceEntry->setLabel('source'); - $jsContactSourceEntry->setResource($vCardSourcePropertyValue); + // Call parent first to handle standard properties + parent::getOnlineServices($card); - if (isset($vCardSourceProperty['PREF']) && !empty($vCardSourceProperty['PREF'])) { - $jsContactSourceEntry->setPref($vCardSourceProperty['PREF']); - } - - if (isset($vCardSourceProperty['MEDIATYPE']) && !empty($vCardSourceProperty['MEDIATYPE'])) { - $jsContactSourceEntry->setMediaType($vCardSourceProperty['MEDIATYPE']); - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the SOURCE property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardSourcePropertyValue)] = $jsContactSourceEntry; - } - } - } + // Get existing services or initialize + $jsContactOnlineProperty = $card->getOnlineServices(); + if (!is_array($jsContactOnlineProperty)) { + $jsContactOnlineProperty = array(); } - // IMPP property mapping - if (in_array("IMPP", $this->vCardChildren)) { - $vCardImppProperties = $this->vCard->IMPP; - - foreach ($vCardImppProperties as $vCardImppProperty) { - if (isset($vCardImppProperty)) { - $vCardImppPropertyValue = $vCardImppProperty->getValue(); + $index = count($jsContactOnlineProperty) + 1; - if (isset($vCardImppPropertyValue) && !empty($vCardImppPropertyValue)) { - $jsContactImppEntry = new Resource(); - $jsContactImppEntry->setAtType("Resource"); - $jsContactImppEntry->setType("username"); - $jsContactImppEntry->setLabel("XMPP"); - $jsContactImppEntry->setResource($vCardImppPropertyValue); + // Map all Roundcube-specific IM properties + $this->readRoundcubeIM("X-AIM", "aim", $jsContactOnlineProperty, $index); + $this->readRoundcubeIM("X-ICQ", "icq", $jsContactOnlineProperty, $index); + $this->readRoundcubeIM("X-MSN", "msn", $jsContactOnlineProperty, $index); + $this->readRoundcubeIM("X-YAHOO", "yahoo", $jsContactOnlineProperty, $index); + $this->readRoundcubeIM("X-JABBER", "jabber", $jsContactOnlineProperty, $index); + $this->readRoundcubeIM("X-SKYPE-USERNAME", "skype", $jsContactOnlineProperty, $index); - if (isset($vCardImppProperty['PREF']) && !empty($vCardImppProperty['PREF'])) { - $jsContactImppEntry->setPref($vCardImppProperty['PREF']); - } - - if (isset($vCardImppProperty['TYPE']) && !empty($vCardImppProperty['TYPE'])) { - $jsContactImppContexts = []; - - foreach ($vCardImppProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactImppContexts['private'] = true; - break; - - case 'work': - $jsContactImppContexts['work'] = true; - break; - - case 'other': - $jsContactImppContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property IMPP: " . $paramValue - ); - break; - } - - $jsContactImppEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactImppContexts) - ? $jsContactImppContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the IMPP property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardImppPropertyValue)] = $jsContactImppEntry; - } - } - } + // Update the card with all online services + if (!empty($jsContactOnlineProperty)) { + $card->setOnlineServices($jsContactOnlineProperty); } + } - // LOGO property mapping - if (in_array("LOGO", $this->vCardChildren)) { - $vCardLogoProperties = $this->vCard->LOGO; - - foreach ($vCardLogoProperties as $vCardLogoProperty) { - if (isset($vCardLogoProperty)) { - $vCardLogoPropertyValue = $vCardLogoProperty->getValue(); - - if (isset($vCardLogoPropertyValue) && !empty($vCardLogoPropertyValue)) { - $jsContactLogoEntry = new Resource(); - $jsContactLogoEntry->setAtType("Resource"); - $jsContactLogoEntry->setType("uri"); - $jsContactLogoEntry->setLabel("logo"); - $jsContactLogoEntry->setResource($vCardLogoPropertyValue); - - if (isset($vCardLogoProperty['PREF']) && !empty($vCardLogoProperty['PREF'])) { - $jsContactLogoEntry->setPref($vCardLogoProperty['PREF']); - } - - if (isset($vCardLogoProperty['TYPE']) && !empty($vCardLogoProperty['TYPE'])) { - $jsContactLogoContexts = []; - - foreach ($vCardLogoProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactLogoContexts['private'] = true; - break; - - case 'work': - $jsContactLogoContexts['work'] = true; - break; - - case 'other': - $jsContactLogoContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property LOGO: " . $paramValue - ); - break; - } - - $jsContactLogoEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactLogoContexts) - ? $jsContactLogoContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the LOGO property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardLogoPropertyValue)] = $jsContactLogoEntry; - } - } - } - } - - // CONTACT-URI property mapping - if (in_array("CONTACT-URI", $this->vCardChildren)) { - $vCardContactUriProperties = $this->vCard->__get("CONTACT-URI"); - - foreach ($vCardContactUriProperties as $vCardContactUriProperty) { - if (isset($vCardContactUriProperty)) { - $vCardContactUriPropertyValue = $vCardContactUriProperty->getValue(); - - if (isset($vCardContactUriPropertyValue) && !empty($vCardContactUriPropertyValue)) { - $jsContactContactUriEntry = new Resource(); - $jsContactContactUriEntry->setAtType("Resource"); - $jsContactContactUriEntry->setType("uri"); - $jsContactContactUriEntry->setLabel("contact-uri"); - $jsContactContactUriEntry->setResource($vCardContactUriPropertyValue); - - if (isset($vCardContactUriProperty['PREF']) && !empty($vCardContactUriProperty['PREF'])) { - $jsContactContactUriEntry->setPref($vCardContactUriProperty['PREF']); - } - - if (isset($vCardContactUriProperty['TYPE']) && !empty($vCardContactUriProperty['TYPE'])) { - $jsContactContactUriContexts = []; - - foreach ($vCardContactUriProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactContactUriContexts['private'] = true; - break; - - case 'work': - $jsContactContactUriContexts['work'] = true; - break; - - case 'other': - $jsContactContactUriContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property CONTACT-URI: " . $paramValue - ); - break; - } - - $jsContactContactUriEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactContactUriContexts) - ? $jsContactContactUriContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the CONTACT-URI property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardContactUriPropertyValue)] = $jsContactContactUriEntry; - } - } - } - } - - // ORG-DIRECTORY property mapping - if (in_array("ORG-DIRECTORY", $this->vCardChildren)) { - $vCardOrgDirectoryProperties = $this->vCard->__get("ORG-DIRECTORY"); - - foreach ($vCardOrgDirectoryProperties as $vCardOrgDirectoryProperty) { - if (isset($vCardOrgDirectoryProperty)) { - $vCardOrgDirectoryPropertyValue = $vCardOrgDirectoryProperty->getValue(); - - if (isset($vCardOrgDirectoryPropertyValue) && !empty($vCardOrgDirectoryPropertyValue)) { - $jsContactOrgDirectoryEntry = new Resource(); - $jsContactOrgDirectoryEntry->setAtType("Resource"); - $jsContactOrgDirectoryEntry->setType("uri"); - $jsContactOrgDirectoryEntry->setLabel("org-directory"); - $jsContactOrgDirectoryEntry->setResource($vCardOrgDirectoryPropertyValue); - - if (isset($vCardOrgDirectoryProperty['PREF']) && !empty($vCardOrgDirectoryProperty['PREF'])) { - $jsContactOrgDirectoryEntry->setPref($vCardOrgDirectoryProperty['PREF']); - } - - if (isset($vCardOrgDirectoryProperty['TYPE']) && !empty($vCardOrgDirectoryProperty['TYPE'])) { - $jsContactOrgDirectoryContexts = []; - - foreach ($vCardOrgDirectoryProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactOrgDirectoryContexts['private'] = true; - break; - - case 'work': - $jsContactOrgDirectoryContexts['work'] = true; - break; - - case 'other': - $jsContactOrgDirectoryContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property ORG-DIRECTORY: " . $paramValue - ); - break; - } - - $jsContactOrgDirectoryEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactOrgDirectoryContexts) - ? $jsContactOrgDirectoryContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the ORG-DIRECTORY property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardOrgDirectoryPropertyValue)] = $jsContactOrgDirectoryEntry; - } - } - } - } - - // SOUND property mapping - if (in_array("SOUND", $this->vCardChildren)) { - $vCardSoundProperties = $this->vCard->SOUND; - - foreach ($vCardSoundProperties as $vCardSoundProperty) { - if (isset($vCardSoundProperty)) { - $vCardSoundPropertyValue = $vCardSoundProperty->getValue(); - - if (isset($vCardSoundPropertyValue) && !empty($vCardSoundPropertyValue)) { - $jsContactSoundEntry = new Resource(); - $jsContactSoundEntry->setAtType("Resource"); - $jsContactSoundEntry->setType("uri"); - $jsContactSoundEntry->setLabel("sound"); - $jsContactSoundEntry->setResource($vCardSoundPropertyValue); - - if (isset($vCardSoundProperty['PREF']) && !empty($vCardSoundProperty['PREF'])) { - $jsContactSoundEntry->setPref($vCardSoundProperty['PREF']); - } - - if (isset($vCardSoundProperty['TYPE']) && !empty($vCardSoundProperty['TYPE'])) { - $jsContactSoundContexts = []; - - foreach ($vCardSoundProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactSoundContexts['private'] = true; - break; - - case 'work': - $jsContactSoundContexts['work'] = true; - break; - - case 'other': - $jsContactSoundContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property SOUND: " . $paramValue - ); - break; - } - - $jsContactSoundEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactSoundContexts) - ? $jsContactSoundContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the SOUND property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardSoundPropertyValue)] = $jsContactSoundEntry; - } - } - } - } - - // URL property mapping - if (in_array("URL", $this->vCardChildren)) { - $vCardUrlProperties = $this->vCard->URL; - - foreach ($vCardUrlProperties as $vCardUrlProperty) { - if (isset($vCardUrlProperty)) { - $vCardUrlPropertyValue = $vCardUrlProperty->getValue(); - - if (isset($vCardUrlPropertyValue) && !empty($vCardUrlPropertyValue)) { - $jsContactUrlEntry = new Resource(); - $jsContactUrlEntry->setAtType("Resource"); - $jsContactUrlEntry->setType("uri"); - $jsContactUrlEntry->setLabel("url"); - $jsContactUrlEntry->setResource($vCardUrlPropertyValue); - - if (isset($vCardUrlProperty['PREF']) && !empty($vCardUrlProperty['PREF'])) { - $jsContactUrlEntry->setPref($vCardUrlProperty['PREF']); - } - - if (isset($vCardUrlProperty['TYPE']) && !empty($vCardUrlProperty['TYPE'])) { - $jsContactUrlContexts = []; - - foreach ($vCardUrlProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactUrlContexts['private'] = true; - break; - - case 'work': - $jsContactUrlContexts['work'] = true; - break; - - case 'other': - $jsContactUrlContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property URL: " . $paramValue - ); - break; - } - - $jsContactUrlEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactUrlContexts) - ? $jsContactUrlContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the URL property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardUrlPropertyValue)] = $jsContactUrlEntry; - } - } - } - } - - // KEY property mapping - if (in_array("KEY", $this->vCardChildren)) { - $vCardKeyProperties = $this->vCard->KEY; - - foreach ($vCardKeyProperties as $vCardKeyProperty) { - if (isset($vCardKeyProperty)) { - $vCardKeyPropertyValue = $vCardKeyProperty->getValue(); - - if (isset($vCardKeyPropertyValue) && !empty($vCardKeyPropertyValue)) { - $jsContactKeyEntry = new Resource(); - $jsContactKeyEntry->setAtType("Resource"); - $jsContactKeyEntry->setType("uri"); - $jsContactKeyEntry->setLabel("key"); - $jsContactKeyEntry->setResource($vCardKeyPropertyValue); - - if (isset($vCardKeyProperty['PREF']) && !empty($vCardKeyProperty['PREF'])) { - $jsContactKeyEntry->setPref($vCardKeyProperty['PREF']); - } - - if (isset($vCardKeyProperty['TYPE']) && !empty($vCardKeyProperty['TYPE'])) { - $jsContactKeyContexts = []; - - foreach ($vCardKeyProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactKeyContexts['private'] = true; - break; - - case 'work': - $jsContactKeyContexts['work'] = true; - break; - - case 'other': - $jsContactKeyContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property KEY: " . $paramValue - ); - break; - } - - $jsContactKeyEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactKeyContexts) - ? $jsContactKeyContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the KEY property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardKeyPropertyValue)] = $jsContactKeyEntry; - } - } - } + /** + * Reads a single Roundcube X-property IM field and appends it to the services list. + * + * @param string $propertyName The vCard property name (e.g., "X-AIM") + * @param string $serviceName The service name for JSContact (e.g., "aim") + * @param array $services Reference to the services array being built + * @param int $index Reference to the current index counter + */ + private function readRoundcubeIM($propertyName, $serviceName, array &$services, &$index) + { + $properties = $this->vCard->__get($propertyName); + if (!AdapterUtil::isSetAndNotNull($properties)) { + return; } - // FBURL property mapping - if (in_array("FBURL", $this->vCardChildren)) { - $vCardFbUrlProperties = $this->vCard->FBURL; - - foreach ($vCardFbUrlProperties as $vCardFbUrlProperty) { - if (isset($vCardFbUrlProperty)) { - $vCardFbUrlPropertyValue = $vCardFbUrlProperty->getValue(); - - if (isset($vCardFbUrlPropertyValue) && !empty($vCardFbUrlPropertyValue)) { - $jsContactFbUrlEntry = new Resource(); - $jsContactFbUrlEntry->setAtType("Resource"); - $jsContactFbUrlEntry->setType("uri"); - $jsContactFbUrlEntry->setLabel("fburl"); - $jsContactFbUrlEntry->setResource($vCardFbUrlPropertyValue); - - if (isset($vCardFbUrlProperty['PREF']) && !empty($vCardFbUrlProperty['PREF'])) { - $jsContactFbUrlEntry->setPref($vCardFbUrlProperty['PREF']); - } - - if (isset($vCardFbUrlProperty['TYPE']) && !empty($vCardFbUrlProperty['TYPE'])) { - $jsContactFbUrlContexts = []; - - foreach ($vCardFbUrlProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactFbUrlContexts['private'] = true; - break; - - case 'work': - $jsContactFbUrlContexts['work'] = true; - break; - - case 'other': - $jsContactFbUrlContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property FBURL: " . $paramValue - ); - break; - } - - $jsContactFbUrlEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactFbUrlContexts) - ? $jsContactFbUrlContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the FBURL property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardFbUrlPropertyValue)] = $jsContactFbUrlEntry; - } - } + foreach ($properties as $property) { + $value = trim((string)$property); + if ($value === "") { + continue; } - } - - // CALADRURI property mapping - if (in_array("CALADRURI", $this->vCardChildren)) { - $vCardCalAdrUriProperties = $this->vCard->CALADRURI; - foreach ($vCardCalAdrUriProperties as $vCardCalAdrUriProperty) { - if (isset($vCardCalAdrUriProperty)) { - $vCardCalAdrUriPropertyValue = $vCardCalAdrUriProperty->getValue(); + $jsContactOnlineEntry = new OnlineService(); + $jsContactOnlineEntry->setService($serviceName); - if (isset($vCardCalAdrUriPropertyValue) && !empty($vCardCalAdrUriPropertyValue)) { - $jsContactCalAdrUriEntry = new Resource(); - $jsContactCalAdrUriEntry->setAtType("Resource"); - $jsContactCalAdrUriEntry->setType("uri"); - $jsContactCalAdrUriEntry->setLabel("caladruri"); - $jsContactCalAdrUriEntry->setResource($vCardCalAdrUriPropertyValue); - - if (isset($vCardCalAdrUriProperty['PREF']) && !empty($vCardCalAdrUriProperty['PREF'])) { - $jsContactCalAdrUriEntry->setPref($vCardCalAdrUriProperty['PREF']); - } - - if (isset($vCardCalAdrUriProperty['TYPE']) && !empty($vCardCalAdrUriProperty['TYPE'])) { - $jsContactCalAdrUriContexts = []; - - foreach ($vCardCalAdrUriProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactCalAdrUriContexts['private'] = true; - break; - - case 'work': - $jsContactCalAdrUriContexts['work'] = true; - break; - - case 'other': - $jsContactCalAdrUriContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property CALADRURI: " . $paramValue - ); - break; - } - - $jsContactCalAdrUriEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactCalAdrUriContexts) - ? $jsContactCalAdrUriContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the CALADRURI property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardCalAdrUriPropertyValue)] = $jsContactCalAdrUriEntry; - } - } + // Set user or uri based on service type + if (in_array($serviceName, ['skype', 'icq', 'msn', 'yahoo'], true)) { + $jsContactOnlineEntry->setUser($value); + } else { + $jsContactOnlineEntry->setUri($value); } - } - - // CALURI property mapping - if (in_array("CALURI", $this->vCardChildren)) { - $vCardCalUriProperties = $this->vCard->CALURI; - - foreach ($vCardCalUriProperties as $vCardCalUriProperty) { - if (isset($vCardCalUriProperty)) { - $vCardCalUriPropertyValue = $vCardCalUriProperty->getValue(); - - if (isset($vCardCalUriPropertyValue) && !empty($vCardCalUriPropertyValue)) { - $jsContactCalUriEntry = new Resource(); - $jsContactCalUriEntry->setAtType("Resource"); - $jsContactCalUriEntry->setType("uri"); - $jsContactCalUriEntry->setLabel("caluri"); - $jsContactCalUriEntry->setResource($vCardCalUriPropertyValue); - if (isset($vCardCalUriProperty['PREF']) && !empty($vCardCalUriProperty['PREF'])) { - $jsContactCalUriEntry->setPref($vCardCalUriProperty['PREF']); - } - - if (isset($vCardCalUriProperty['TYPE']) && !empty($vCardCalUriProperty['TYPE'])) { - $jsContactCalUriContexts = []; - - foreach ($vCardCalUriProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactCalUriContexts['private'] = true; - break; - - case 'work': - $jsContactCalUriContexts['work'] = true; - break; - - case 'other': - $jsContactCalUriContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property CALURI: " . $paramValue - ); - break; - } - - $jsContactCalUriEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactCalUriContexts) - ? $jsContactCalUriContexts - : null - ); - } - } + $jsContactOnlineEntry->setLabel($propertyName); - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the CALURI property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardCalUriPropertyValue)] = $jsContactCalUriEntry; - } - } + // Map contexts from TYPE parameter + $contexts = Util::vCardTypeParamToContexts($property); + if (!empty($contexts)) { + $jsContactOnlineEntry->setContexts($contexts); } - } - - // X-AIM property mapping - // Note: The vCard X-AIM property is Roundcube-specific - if (in_array("X-AIM", $this->vCardChildren)) { - $vCardXAimProperties = $this->vCard->__get("X-AIM"); - - foreach ($vCardXAimProperties as $vCardXAimProperty) { - if (isset($vCardXAimProperty)) { - $vCardXAimPropertyValue = $vCardXAimProperty->getValue(); - - if (isset($vCardXAimPropertyValue) && !empty($vCardXAimPropertyValue)) { - $jsContactXAimEntry = new Resource(); - $jsContactXAimEntry->setAtType("Resource"); - $jsContactXAimEntry->setType("username"); - $jsContactXAimEntry->setLabel("X-AIM"); - $jsContactXAimEntry->setResource($vCardXAimPropertyValue); - - if (isset($vCardXAimProperty['PREF']) && !empty($vCardXAimProperty['PREF'])) { - $jsContactXAimEntry->setPref($vCardXAimProperty['PREF']); - } - - if (isset($vCardXAimProperty['TYPE']) && !empty($vCardXAimProperty['TYPE'])) { - $jsContactXAimContexts = []; - - foreach ($vCardXAimProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactXAimContexts['private'] = true; - break; - - case 'work': - $jsContactXAimContexts['work'] = true; - break; - - case 'other': - $jsContactXAimContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property X-AIM: " . $paramValue - ); - break; - } - - $jsContactXAimEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactXAimContexts) - ? $jsContactXAimContexts - : null - ); - } - } - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the X-AIM property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardXAimPropertyValue)] = $jsContactXAimEntry; - } - } + // Map preference + $pref = Util::vCardPrefParamToInt($property); + if ($pref !== null) { + $jsContactOnlineEntry->setPref($pref); } - } - - // X-ICQ property mapping - // Note: The vCard X-ICQ property is Roundcube-specific - if (in_array("X-ICQ", $this->vCardChildren)) { - $vCardXIcqProperties = $this->vCard->__get("X-ICQ"); - foreach ($vCardXIcqProperties as $vCardXIcqProperty) { - if (isset($vCardXIcqProperty)) { - $vCardXIcqPropertyValue = $vCardXIcqProperty->getValue(); - - if (isset($vCardXIcqPropertyValue) && !empty($vCardXIcqPropertyValue)) { - $jsContactXIcqEntry = new Resource(); - $jsContactXIcqEntry->setAtType("Resource"); - $jsContactXIcqEntry->setType("username"); - $jsContactXIcqEntry->setLabel("X-ICQ"); - $jsContactXIcqEntry->setResource($vCardXIcqPropertyValue); - - if (isset($vCardXIcqProperty['PREF']) && !empty($vCardXIcqProperty['PREF'])) { - $jsContactXIcqEntry->setPref($vCardXIcqProperty['PREF']); - } - - if (isset($vCardXIcqProperty['TYPE']) && !empty($vCardXIcqProperty['TYPE'])) { - $jsContactXIcqContexts = []; - - foreach ($vCardXIcqProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactXIcqContexts['private'] = true; - break; - - case 'work': - $jsContactXIcqContexts['work'] = true; - break; - - case 'other': - $jsContactXIcqContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property X-ICQ: " . $paramValue - ); - break; - } - - $jsContactXIcqEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactXIcqContexts) - ? $jsContactXIcqContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the X-ICQ property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardXIcqPropertyValue)] = $jsContactXIcqEntry; - } - } - } + $services['os' . $index++] = $jsContactOnlineEntry; } - - // X-MSN property mapping - // Note: The vCard X-MSN property is Roundcube-specific - if (in_array("X-MSN", $this->vCardChildren)) { - $vCardXMsnProperties = $this->vCard->__get("X-MSN"); - - foreach ($vCardXMsnProperties as $vCardXMsnProperty) { - if (isset($vCardXMsnProperty)) { - $vCardXMsnPropertyValue = $vCardXMsnProperty->getValue(); - - if (isset($vCardXMsnPropertyValue) && !empty($vCardXMsnPropertyValue)) { - $jsContactXMsnEntry = new Resource(); - $jsContactXMsnEntry->setAtType("Resource"); - $jsContactXMsnEntry->setType("username"); - $jsContactXMsnEntry->setLabel("X-MSN"); - $jsContactXMsnEntry->setResource($vCardXMsnPropertyValue); - - if (isset($vCardXMsnProperty['PREF']) && !empty($vCardXMsnProperty['PREF'])) { - $jsContactXMsnEntry->setPref($vCardXMsnProperty['PREF']); - } - - if (isset($vCardXMsnProperty['TYPE']) && !empty($vCardXMsnProperty['TYPE'])) { - $jsContactXMsnContexts = []; - - foreach ($vCardXMsnProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactXMsnContexts['private'] = true; - break; - - case 'work': - $jsContactXMsnContexts['work'] = true; - break; - - case 'other': - $jsContactXMsnContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property X-MSN: " . $paramValue - ); - break; - } - - $jsContactXMsnEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactXMsnContexts) - ? $jsContactXMsnContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the X-MSN property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardXMsnPropertyValue)] = $jsContactXMsnEntry; - } - } - } - } - - // X-YAHOO property mapping - // Note: The vCard X-YAHOO property is Roundcube-specific - if (in_array("X-YAHOO", $this->vCardChildren)) { - $vCardXYahooProperties = $this->vCard->__get("X-YAHOO"); - - foreach ($vCardXYahooProperties as $vCardXYahooProperty) { - if (isset($vCardXYahooProperty)) { - $vCardXYahooPropertyValue = $vCardXYahooProperty->getValue(); - - if (isset($vCardXYahooPropertyValue) && !empty($vCardXYahooPropertyValue)) { - $jsContactXYahooEntry = new Resource(); - $jsContactXYahooEntry->setAtType("Resource"); - $jsContactXYahooEntry->setType("username"); - $jsContactXYahooEntry->setLabel("X-YAHOO"); - $jsContactXYahooEntry->setResource($vCardXYahooPropertyValue); - - if (isset($vCardXYahooProperty['PREF']) && !empty($vCardXYahooProperty['PREF'])) { - $jsContactXYahooEntry->setPref($vCardXYahooProperty['PREF']); - } - - if (isset($vCardXYahooProperty['TYPE']) && !empty($vCardXYahooProperty['TYPE'])) { - $jsContactXYahooContexts = []; - - foreach ($vCardXYahooProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactXYahooContexts['private'] = true; - break; - - case 'work': - $jsContactXYahooContexts['work'] = true; - break; - - case 'other': - $jsContactXYahooContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property X-YAHOO: " . $paramValue - ); - break; - } - - $jsContactXYahooEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactXYahooContexts) - ? $jsContactXYahooContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the X-YAHOO property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardXYahooPropertyValue)] = $jsContactXYahooEntry; - } - } - } - } - - // X-JABBER property mapping - // Note: The vCard X-JABBER property is Roundcube-specific - if (in_array("X-JABBER", $this->vCardChildren)) { - $vCardXJabberProperties = $this->vCard->__get("X-JABBER"); - - foreach ($vCardXJabberProperties as $vCardXJabberProperty) { - if (isset($vCardXJabberProperty)) { - $vCardXJabberPropertyValue = $vCardXJabberProperty->getValue(); - - if (isset($vCardXJabberPropertyValue) && !empty($vCardXJabberPropertyValue)) { - $jsContactXJabberEntry = new Resource(); - $jsContactXJabberEntry->setAtType("Resource"); - $jsContactXJabberEntry->setType("username"); - $jsContactXJabberEntry->setLabel("X-JABBER"); - $jsContactXJabberEntry->setResource($vCardXJabberPropertyValue); - - if (isset($vCardXJabberProperty['PREF']) && !empty($vCardXJabberProperty['PREF'])) { - $jsContactXJabberEntry->setPref($vCardXJabberProperty['PREF']); - } - - if (isset($vCardXJabberProperty['TYPE']) && !empty($vCardXJabberProperty['TYPE'])) { - $jsContactXJabberContexts = []; - - foreach ($vCardXJabberProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactXJabberContexts['private'] = true; - break; - - case 'work': - $jsContactXJabberContexts['work'] = true; - break; - - case 'other': - $jsContactXJabberContexts = null; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property X-JABBER: " . $paramValue - ); - break; - } - - $jsContactXJabberEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactXJabberContexts) - ? $jsContactXJabberContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the X-JABBER property's value to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardXJabberPropertyValue)] = $jsContactXJabberEntry; - } - } - } - } - - // X-SKYPE-USERNAME property mapping - // Note: The vCard X-SKYPE-USERNAME property is Roundcube-specific - if (in_array("X-SKYPE-USERNAME", $this->vCardChildren)) { - $vCardXSkypeUsernameProperties = $this->vCard->__get("X-SKYPE-USERNAME"); - - foreach ($vCardXSkypeUsernameProperties as $vCardXSkypeUsernameProperty) { - if (isset($vCardXSkypeUsernameProperty)) { - $vCardXSkypeUsernamePropertyValue = $vCardXSkypeUsernameProperty->getValue(); - - if (isset($vCardXSkypeUsernamePropertyValue) && !empty($vCardXSkypeUsernamePropertyValue)) { - $jsContactXSkypeUsernameEntry = new Resource(); - $jsContactXSkypeUsernameEntry->setAtType("Resource"); - $jsContactXSkypeUsernameEntry->setType("username"); - $jsContactXSkypeUsernameEntry->setLabel("X-SKYPE-USERNAME"); - $jsContactXSkypeUsernameEntry->setResource($vCardXSkypeUsernamePropertyValue); - - if ( - isset($vCardXSkypeUsernameProperty['PREF']) - && !empty($vCardXSkypeUsernameProperty['PREF']) - ) { - $jsContactXSkypeUsernameEntry->setPref($vCardXSkypeUsernameProperty['PREF']); - } - - if ( - isset($vCardXSkypeUsernameProperty['TYPE']) - && !empty($vCardXSkypeUsernameProperty['TYPE']) - ) { - $jsContactXSkypeUsernameContexts = []; - - foreach ($vCardXSkypeUsernameProperty['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactXSkypeUsernameContexts['private'] = true; - break; - - case 'work': - $jsContactXSkypeUsernameContexts['work'] = true; - break; - - case 'other': - $jsContactXSkypeUsernameContexts = null; - break; - - default: - // Use the audriga-specific value "audriga.eu:other" to designate - // other values for contexts - $jsContactXSkypeUsernameContexts['audriga.eu:other'] = true; - $this->logger = Logger::getInstance(); - $this->logger->warning( - "Unknown vCard TYPE parameter value encountered - for vCard property X-SKYPE-USERNAME: " . $paramValue - ); - break; - } - - $jsContactXSkypeUsernameEntry->setContexts( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactXSkypeUsernameContexts) - ? $jsContactXSkypeUsernameContexts - : null - ); - } - } - - // Since "online" is a map and key creation for the map keys is not specified, we use - // the MD5 hash of the X-SKYPE-USERNAME property's value - // to create the key of the entry in "online" - $jsContactOnlineProperty[md5($vCardXSkypeUsernamePropertyValue)] - = $jsContactXSkypeUsernameEntry; - } - } - } - } - - return $jsContactOnlineProperty; } /** - * This function maps all JSContact "online" entries that correspond to the vCard X-AIM property to it + * This function maps the vCard "X-GENDER" property to the JSContact "speakToAs" property + * Falls back to standard GRAMGENDER handling if X-GENDER is not "male" or "female" * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card The ContactCard to populate */ - public function setXAim($jsContactOnlineMap) + public function getGramGender(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { - return; - } + $xGender = $this->vCard->__get("X-GENDER"); + if (AdapterUtil::isSetAndNotNull($xGender)) { + $value = trim((string)$xGender); - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardXAimParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "X-AIM") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardXAimParams['type'] = 'home'; - break; - - case 'work': - $vCardXAimParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the X-AIM vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // If $resourceObjectContexts is null, then we set the vCard type to be 'other' - $vCardXAimParams['type'] = 'other'; - } - - if (isset($resourceObjectPref)) { - $vCardXAimParams['pref'] = $resourceObjectPref; - } + if ($value !== "") { + $normalizedValue = strtolower($value); - $this->vCard->add("X-AIM", $resourceObjectResource, $vCardXAimParams); + if ($normalizedValue === "male" || $normalizedValue === "female") { + $jsContactSpeakToAsProperty = $card->getSpeakToAs(); + if (!$jsContactSpeakToAsProperty) { + $jsContactSpeakToAsProperty = new SpeakToAs(); } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard X-AIM property"); + $jsContactSpeakToAsProperty->setGrammaticalGender($normalizedValue); + $card->setSpeakToAs($jsContactSpeakToAsProperty); + return; } } } + + // Fall back to parent implementation for standard GRAMGENDER + parent::getGramGender($card); } /** - * This function maps all JSContact "online" entries that correspond to the vCard X-ICQ property to it + * This function maps the vCard "RELATED", "X-MANAGER", "X-ASSISTANT" and "X-SPOUSE" + * properties to the JSContact "relatedTo" property * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card The ContactCard to populate */ - public function setXIcq($jsContactOnlineMap) + public function getRelatedTo(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { - return; + // Call parent first to handle standard RELATED property + parent::getRelatedTo($card); + + // Get existing relations or initialize + $jsContactRelatedToProperty = $card->getRelatedTo(); + if (!is_array($jsContactRelatedToProperty)) { + $jsContactRelatedToProperty = array(); } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardXIcqParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "X-ICQ") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardXIcqParams['type'] = 'home'; - break; - - case 'work': - $vCardXIcqParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the X-ICQ vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // If $resourceObjectContexts is null, then we set the vCard type to be 'other' - $vCardXIcqParams['type'] = 'other'; - } + // Map Roundcube-specific X-properties + $this->readRelatedX("X-MANAGER", "manager", $jsContactRelatedToProperty); + $this->readRelatedX("X-ASSISTANT", "assistant", $jsContactRelatedToProperty); + $this->readRelatedX("X-SPOUSE", "spouse", $jsContactRelatedToProperty); - if (isset($resourceObjectPref)) { - $vCardXIcqParams['pref'] = $resourceObjectPref; - } - - $this->vCard->add("X-ICQ", $resourceObjectResource, $vCardXIcqParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard X-ICQ property"); - } - } + // Update the card with all relations + if (!empty($jsContactRelatedToProperty)) { + $card->setRelatedTo($jsContactRelatedToProperty); } } /** - * This function maps all JSContact "online" entries that correspond to the vCard X-MSN property to it + * Reads a single Roundcube X-relation field and adds it to the relations map + * under the given relation type. * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param string $propertyName The vCard property name (e.g., "X-MANAGER") + * @param string $relationType The relation type for JSContact (e.g., "manager") + * @param array $relations Reference to the relations array being built */ - public function setXMsn($jsContactOnlineMap) + private function readRelatedX($propertyName, $relationType, array &$relations) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $properties = $this->vCard->__get($propertyName); + if (!AdapterUtil::isSetAndNotNull($properties)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardXMsnParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "X-MSN") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardXMsnParams['type'] = 'home'; - break; - - case 'work': - $vCardXMsnParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the X-MSN vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // If $resourceObjectContexts is null, then we set the vCard type to be 'other' - $vCardXMsnParams['type'] = 'other'; - } + foreach ($properties as $property) { + $uid = trim((string)$property); + if ($uid === "") { + continue; + } - if (isset($resourceObjectPref)) { - $vCardXMsnParams['pref'] = $resourceObjectPref; - } + if (!isset($relations[$uid])) { + $relations[$uid] = new Relation(); + } - $this->vCard->add("X-MSN", $resourceObjectResource, $vCardXMsnParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard X-MSN property"); - } + $relation = $relations[$uid]; + $types = $relation->getRelation(); + if (!is_array($types)) { + $types = array(); } + $types[$relationType] = true; + $relation->setRelation($types); } } /** - * This function maps all JSContact "online" entries that correspond to the vCard X-YAHOO property to it + * This function maps the vCard "ORG" and "X-DEPARTMENT" properties to the JSContact "organizations" property + * Note: X-DEPARTMENT values are mapped to the "units" property of Organization objects * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects + * @param ContactCard $card The ContactCard to populate */ - public function setXYahoo($jsContactOnlineMap) + public function getOrganizations(ContactCard $card) { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + // Call parent first to handle standard ORG property + parent::getOrganizations($card); + + $departments = $this->vCard->__get("X-DEPARTMENT"); + if (!AdapterUtil::isSetAndNotNull($departments)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardXYahooParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "X-YAHOO") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardXYahooParams['type'] = 'home'; - break; - - case 'work': - $vCardXYahooParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the X-YAHOO vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // If $resourceObjectContexts is null, then we set the vCard type to be 'other' - $vCardXYahooParams['type'] = 'other'; - } - - if (isset($resourceObjectPref)) { - $vCardXYahooParams['pref'] = $resourceObjectPref; - } + // Get existing organizations or initialize + $jsContactOrganizationsProperty = $card->getOrganizations(); + if (!is_array($jsContactOrganizationsProperty)) { + $jsContactOrganizationsProperty = array(); + } - $this->vCard->add("X-YAHOO", $resourceObjectResource, $vCardXYahooParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard X-YAHOO property"); - } - } + // If there is no ORG yet, create it to add X-DEPARTMENT. + if (empty($jsContactOrganizationsProperty)) { + $org = new Organization(); + $jsContactOrganizationsProperty['o1'] = $org; } - } - /** - * This function maps all JSContact "online" entries that correspond to the vCard X-JABBER property to it - * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects - */ - public function setXJabber($jsContactOnlineMap) - { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { + $firstKey = array_key_first($jsContactOrganizationsProperty); + $firstOrg = $jsContactOrganizationsProperty[$firstKey]; + + if (!($firstOrg instanceof Organization)) { return; } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardXJabberParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "X-JABBER") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardXJabberParams['type'] = 'home'; - break; - - case 'work': - $vCardXJabberParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the X-JABBER vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // If $resourceObjectContexts is null, then we set the vCard type to be 'other' - $vCardXJabberParams['type'] = 'other'; - } - - if (isset($resourceObjectPref)) { - $vCardXJabberParams['pref'] = $resourceObjectPref; - } + $units = $firstOrg->getUnits(); + if (!is_array($units)) { + $units = array(); + } - $this->vCard->add("X-JABBER", $resourceObjectResource, $vCardXJabberParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard X-JABBER property"); - } + foreach ($departments as $dept) { + $value = trim((string)$dept); + if ($value !== "") { + $units[] = $value; } } - } - /** - * This function maps all JSContact "online" entries that correspond to the vCard X-SKYPE-USERNAME property to it - * - * @param array|null $jsContactOnlineMap - * The "online" JSContact property as a map of IDs to Resource objects - */ - public function setXSkypeUsername($jsContactOnlineMap) - { - if (!isset($jsContactOnlineMap) || empty($jsContactOnlineMap)) { - return; + if (!empty($units)) { + $units = array_values(array_unique($units, SORT_STRING)); + $firstOrg->setUnits($units); } - foreach ($jsContactOnlineMap as $id => $resourceObject) { - if (isset($resourceObject) && !empty($resourceObject)) { - $resourceObjectLabel = $resourceObject->label; - $resourceObjectResource = $resourceObject->resource; - $resourceObjectContexts = $resourceObject->contexts; - $resourceObjectPref = $resourceObject->pref; - $vCardXSkypeUsernameParams = []; - - if (isset($resourceObjectLabel) && !empty($resourceObjectLabel)) { - if ( - strcmp($resourceObjectLabel, "X-SKYPE-USERNAME") === 0 - && isset($resourceObjectResource) && !empty($resourceObjectResource) - ) { - if (isset($resourceObjectContexts) && !empty($resourceObjectContexts)) { - foreach ($resourceObjectContexts as $resourceObjectContext => $booleanValue) { - switch ($resourceObjectContext) { - case 'private': - $vCardXSkypeUsernameParams['type'] = 'home'; - break; - - case 'work': - $vCardXSkypeUsernameParams['type'] = 'work'; - break; - - default: - $this->logger->error("Unknown value for the \"contexts\" property of a - Resource object in the JSContact \"online\" property encountered during - conversion to the X-SKYPE-USERNAME vCard property. - Encountered value is: " . $resourceObjectContext); - break; - } - } - } else { // If $resourceObjectContexts is null, then we set the vCard type to be 'other' - $vCardXSkypeUsernameParams['type'] = 'other'; - } - - if (isset($resourceObjectPref)) { - $vCardXSkypeUsernameParams['pref'] = $resourceObjectPref; - } - - $this->vCard->add("X-SKYPE-USERNAME", $resourceObjectResource, $vCardXSkypeUsernameParams); - } - } else { - throw new InvalidArgumentException("\"label\" property of \"online\" property entry - not set during conversion to vCard X-SKYPE-USERNAME property"); - } - } - } + $jsContactOrganizationsProperty[$firstKey] = $firstOrg; + $card->setOrganizations($jsContactOrganizationsProperty); } /** - * This function maps the vCard "X-GENDER" property to the JSContact "speakToAs" property + * This function maps the vCard X-MAIDENNAME (Roundcube-specific property) + * to the JSContact "audriga.eu/roundcube:maidenName" property * - * @return SpeakToAs|null The "speakToAs" JSContact property as a SpeakToAs object + * @param ContactCard $card The ContactCard to populate */ - public function getSpeakToAs() + public function getMaidenName(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactSpeakToAsProperty = null; - - // X-GENDER property mapping - // Note: The vCard X-GENDER property is Roundcube-specific - if (in_array("X-GENDER", $this->vCardChildren)) { - $vCardXGenderProperty = $this->vCard->__get("X-GENDER"); - - if (isset($vCardXGenderProperty)) { - $vCardXGenderPropertyValue = $vCardXGenderProperty->getValue(); - - if (isset($vCardXGenderPropertyValue) && !empty($vCardXGenderPropertyValue)) { - $jsContactGrammaticalGenderValue = null; - - // The Roundcube-specific X-GENDER can only take the values "male" and "female" - switch ($vCardXGenderPropertyValue) { - case 'male': - $jsContactGrammaticalGenderValue = 'male'; - break; - - case 'female': - $jsContactGrammaticalGenderValue = 'female'; - break; - - default: - $this->logger = Logger::getInstance(); - $this->logger->error( - "Unknown vCard X-GENDER property value encountered: " . $vCardXGenderPropertyValue - ); - $this->logger->warning("Setting JSContact grammaticalGender value to null"); - $jsContactGrammaticalGenderValue = null; - break; - } - - if (!is_null($jsContactGrammaticalGenderValue)) { - $jsContactSpeakToAsProperty = new SpeakToAs(); - $jsContactSpeakToAsProperty->setAtType("SpeakToAs"); - $jsContactSpeakToAsProperty->setGrammaticalGender($jsContactGrammaticalGenderValue); - } - } + $xMaidenName = $this->vCard->__get("X-MAIDENNAME"); + if (AdapterUtil::isSetAndNotNull($xMaidenName)) { + $value = trim((string)$xMaidenName); + if ($value !== "") { + $card->setProperty("audriga.eu/roundcube:maidenName", $value); } } - - return $jsContactSpeakToAsProperty; } /** - * This function maps the JSContact "speakToAs" property to the vCard X-GENDER property + * This function maps entries of the JSContact "anniversaries" property corresponding to + * the vCard X-ANNIVERSARY property to it + * Note: The vCard X-ANNIVERSARY property is Roundcube-specific * - * @param SpeakToAs|null $jsContactSpeakToAs - * The "speakToAs" JSContact property as a SpeakToAs object + * @param ContactCard $card The ContactCard containing anniversaries */ - public function setXGender($jsContactSpeakToAs) + public function setAnniversaries(ContactCard $card) { - if (!isset($jsContactSpeakToAs) || empty($jsContactSpeakToAs)) { + // Call parent first to handle standard properties + parent::setAnniversaries($card); + + $jsContactAnniversaries = $card->getAnniversaries(); + if (!is_array($jsContactAnniversaries) || empty($jsContactAnniversaries)) { return; } - $jsContactSpeakToAsGrammaticalGender = $jsContactSpeakToAs->grammaticalGender; + $writtenDates = array(); - if (isset($jsContactSpeakToAsGrammaticalGender) && !empty($jsContactSpeakToAsGrammaticalGender)) { - $vCardXGenderValue = null; + foreach ($jsContactAnniversaries as $id => $jsContactAnniversary) { + if (!($jsContactAnniversary instanceof Anniversary)) { + continue; + } - switch ($jsContactSpeakToAsGrammaticalGender) { - case 'male': - $vCardXGenderValue = 'male'; - break; + $jsContactAnniversaryLabel = $jsContactAnniversary->getLabel(); + $jsContactAnniversaryValue = $jsContactAnniversary->getDate(); - case 'female': - $vCardXGenderValue = 'female'; - break; + $label = strtolower(trim((string) $jsContactAnniversaryLabel)); + $date = trim((string) $jsContactAnniversaryValue); - default: - throw new InvalidArgumentException( - "Unknown JSContact value for the property \"grammaticalGender\" - of the \"speakToAs\" property used during conversion to vCard X-GENDER property. - Encountered value is: " . $jsContactSpeakToAsGrammaticalGender - ); - break; + if ($label !== 'x-anniversary' || $date === '') { + continue; + } + + $normalizedDate = AdapterUtil::parseDateTime($date, 'Y-m-d', 'Y-m-d', 'Ymd'); + if ($normalizedDate === null) { + continue; } - if (!is_null($vCardXGenderValue)) { - $this->vCard->add("X-GENDER", $vCardXGenderValue); + if (isset($writtenDates[$normalizedDate])) { + continue; } + + $writtenDates[$normalizedDate] = true; + $this->vCard->add('X-ANNIVERSARY', $normalizedDate); } } /** - * This function maps the vCard "RELATED", "X-MANAGER", "X-ASSISTANT" and "X-SPOUSE" - * properties to the JSContact "relatedTo" property + * This function maps the JSContact "onlineServices" property to vCard properties including + * Roundcube-specific X- properties for instant messaging services * - * @return array|null The "relatedTo" JSContact property as a map of UIDs to Relation objects + * @param ContactCard $card The ContactCard containing online services */ - public function getRelatedTo() + public function setOnlineServices(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + // Call parent first to handle standard properties + parent::setOnlineServices($card); + + $services = $card->getOnlineServices(); + if (!is_array($services)) { return; } - $jsContactRelatedToProperty = null; - - // RELATED property mapping - if (in_array("RELATED", $this->vCardChildren)) { - $vCardRelatedProperties = $this->vCard->RELATED; - - foreach ($vCardRelatedProperties as $vCardRelatedProperty) { - if (isset($vCardRelatedProperty)) { - $vCardRelatedPropertyValue = $vCardRelatedProperty->getValue(); - - if (isset($vCardRelatedPropertyValue) && !empty($vCardRelatedPropertyValue)) { - $jsContactRelation = new Relation(); - $jsContactRelation->setAtType("Relation"); + $serviceToXProperty = array( + 'aim' => 'X-AIM', + 'x-aim' => 'X-AIM', + 'icq' => 'X-ICQ', + 'x-icq' => 'X-ICQ', + 'msn' => 'X-MSN', + 'x-msn' => 'X-MSN', + 'yahoo' => 'X-YAHOO', + 'x-yahoo' => 'X-YAHOO', + 'jabber' => 'X-JABBER', + 'x-jabber' => 'X-JABBER', + 'skype' => 'X-SKYPE-USERNAME', + 'x-skype' => 'X-SKYPE-USERNAME', + 'x-skype-username' => 'X-SKYPE-USERNAME', + ); - if (isset($vCardRelatedProperty['TYPE']) && !empty($vCardRelatedProperty)) { - $jsContactRelationMap = []; - - foreach ($vCardRelatedProperty['TYPE'] as $paramValue) { - $jsContactRelationMap[$paramValue] = true; - } - - $jsContactRelation->setRelation( - AdapterUtil::isSetNotNullAndNotEmpty($jsContactRelationMap) - ? $jsContactRelationMap - : null - ); - } else { - // According to IETF draft: - // If no relation type given, "relation" property must contain an empty object - $jsContactRelation->setRelation(json_decode("{}")); - } - - $jsContactRelatedToProperty[$vCardRelatedPropertyValue] = $jsContactRelation; - } - } + foreach ($services as $id => $service) { + if (!($service instanceof OnlineService)) { + continue; } - } - // X-MANAGER property mapping - // Note: The vCard X-MANAGER property is Roundcube-specific - if (in_array("X-MANAGER", $this->vCardChildren)) { - $vCardXManagerProperties = $this->vCard->__get("X-MANAGER"); + $xProperty = null; - foreach ($vCardXManagerProperties as $vCardXManagerProperty) { - if (isset($vCardXManagerProperty)) { - $vCardXManagerPropertyValue = $vCardXManagerProperty->getValue(); + $resourceObjectLabel = $service->getLabel(); + $label = strtoupper((string)$resourceObjectLabel); + $roundcubeProps = array('X-AIM', 'X-ICQ', 'X-MSN', 'X-YAHOO', 'X-JABBER', 'X-SKYPE-USERNAME'); - if (isset($vCardXManagerPropertyValue) && !empty($vCardXManagerPropertyValue)) { - $jsContactRelation = new Relation(); - $jsContactRelation->setAtType("Relation"); - - $jsContactRelation->setRelation(array("manager" => true)); - - $jsContactRelatedToProperty[$vCardXManagerPropertyValue] = $jsContactRelation; - } + if (in_array($label, $roundcubeProps, true)) { + $xProperty = $label; + } elseif ($service->getService()) { + $serviceName = strtolower((string)$service->getService()); + if (isset($serviceToXProperty[$serviceName])) { + $xProperty = $serviceToXProperty[$serviceName]; } } - } - - // X-ASSISTANT property mapping - // Note: The vCard X-ASSISTANT property is Roundcube-specific - if (in_array("X-ASSISTANT", $this->vCardChildren)) { - $vCardXAssistantProperties = $this->vCard->__get("X-ASSISTANT"); - - foreach ($vCardXAssistantProperties as $vCardXAssistantProperty) { - if (isset($vCardXAssistantProperty)) { - $vCardXAssistantPropertyValue = $vCardXAssistantProperty->getValue(); - - if (isset($vCardXAssistantPropertyValue) && !empty($vCardXAssistantPropertyValue)) { - $jsContactRelation = new Relation(); - $jsContactRelation->setAtType("Relation"); - $jsContactRelation->setRelation(array("assistant" => true)); - - $jsContactRelatedToProperty[$vCardXAssistantPropertyValue] = $jsContactRelation; - } + if ($xProperty) { + $value = $service->getUri(); + if (!$value) { + $value = $service->getUser(); } - } - } - - // X-SPOUSE property mapping - // Note: The vCard X-SPOUSE property is Roundcube-specific - if (in_array("X-SPOUSE", $this->vCardChildren)) { - $vCardXSpouseProperties = $this->vCard->__get("X-SPOUSE"); - - foreach ($vCardXSpouseProperties as $vCardXSpouseProperty) { - if (isset($vCardXSpouseProperty)) { - $vCardXSpousePropertyValue = $vCardXSpouseProperty->getValue(); - - if (isset($vCardXSpousePropertyValue) && !empty($vCardXSpousePropertyValue)) { - $jsContactRelation = new Relation(); - $jsContactRelation->setAtType("Relation"); - - $jsContactRelation->setRelation(array("spouse" => true)); - $jsContactRelatedToProperty[$vCardXSpousePropertyValue] = $jsContactRelation; - } + if ($value) { + $this->vCard->add($xProperty, $value); } } } - - return $jsContactRelatedToProperty; } /** - * This function maps entries of the JSContact "relatedTo" property that correspond to - * the vCard X-MANAGER property to it + * This function maps the JSContact "speakToAs" property to the vCard X-GENDER property + * Falls back to standard GRAMGENDER for values other than "male" or "female" * - * @param array|null $jsContactRelatedTo - * The "relatedTo" JSContact property as a map of strings to Relation objects + * @param ContactCard $card The ContactCard containing speakToAs information */ - public function setXManager($jsContactRelatedTo) + public function setGramGender(ContactCard $card) { - if (!isset($jsContactRelatedTo) || empty($jsContactRelatedTo)) { + $jsContactSpeakToAs = $card->getSpeakToAs(); + if (!$jsContactSpeakToAs) { return; } - foreach ($jsContactRelatedTo as $relatedUid => $jsContactRelation) { - if (isset($relatedUid) && !empty($relatedUid)) { - if (isset($jsContactRelation) && !empty($jsContactRelation)) { - $jsContactRelationValues = $jsContactRelation->relation; - if (isset($jsContactRelationValues) && !empty($jsContactRelationValues)) { - foreach ($jsContactRelationValues as $jsContactRelationValue => $booleanValue) { - if ( - isset($jsContactRelationValue) - && !empty($jsContactRelationValue) - && strcmp($jsContactRelationValue, "manager") === 0 - ) { - $this->vCard->add("X-MANAGER", $relatedUid); - } - } - } - } - } + $jsContactSpeakToAsGrammaticalGender = $jsContactSpeakToAs->getGrammaticalGender(); + if (!$jsContactSpeakToAsGrammaticalGender) { + parent::setGramGender($card); + return; } - } - /** - * This function maps entries of the JSContact "relatedTo" property that correspond to - * the vCard X-ASSISTANT property to it - * - * @param array|null $jsContactRelatedTo - * The "relatedTo" JSContact property as a map of strings to Relation objects - */ - public function setXAssistant($jsContactRelatedTo) - { - if (!isset($jsContactRelatedTo) || empty($jsContactRelatedTo)) { + if ($jsContactSpeakToAsGrammaticalGender === "male" || $jsContactSpeakToAsGrammaticalGender === "female") { + $this->vCard->add("X-GENDER", $jsContactSpeakToAsGrammaticalGender); return; } - foreach ($jsContactRelatedTo as $relatedUid => $jsContactRelation) { - if (isset($relatedUid) && !empty($relatedUid)) { - if (isset($jsContactRelation) && !empty($jsContactRelation)) { - $jsContactRelationValues = $jsContactRelation->relation; - if (isset($jsContactRelationValues) && !empty($jsContactRelationValues)) { - foreach ($jsContactRelationValues as $jsContactRelationValue => $booleanValue) { - if ( - isset($jsContactRelationValue) - && !empty($jsContactRelationValue) - && strcmp($jsContactRelationValue, "assistant") === 0 - ) { - $this->vCard->add("X-ASSISTANT", $relatedUid); - } - } - } - } - } - } + // Fall back to parent implementation for other values + parent::setGramGender($card); } /** - * This function maps entries of the JSContact "relatedTo" property that correspond to - * the vCard X-SPOUSE property to it + * This function maps the JSContact "relatedTo" property to vCard RELATED and + * Roundcube-specific X-MANAGER, X-ASSISTANT, and X-SPOUSE properties * - * @param array|null $jsContactRelatedTo - * The "relatedTo" JSContact property as a map of strings to Relation objects + * @param ContactCard $card The ContactCard containing relations */ - public function setXSpouse($jsContactRelatedTo) + public function setRelatedTo(ContactCard $card) { - if (!isset($jsContactRelatedTo) || empty($jsContactRelatedTo)) { + // Call parent first to handle standard RELATED property + parent::setRelatedTo($card); + + $jsContactRelatedTo = $card->getRelatedTo(); + if (!is_array($jsContactRelatedTo)) { return; } foreach ($jsContactRelatedTo as $relatedUid => $jsContactRelation) { - if (isset($relatedUid) && !empty($relatedUid)) { - if (isset($jsContactRelation) && !empty($jsContactRelation)) { - $jsContactRelationValues = $jsContactRelation->relation; - if (isset($jsContactRelationValues) && !empty($jsContactRelationValues)) { - foreach ($jsContactRelationValues as $jsContactRelationValue => $booleanValue) { - if ( - isset($jsContactRelationValue) - && !empty($jsContactRelationValue) - && strcmp($jsContactRelationValue, "spouse") === 0 - ) { - $this->vCard->add("X-SPOUSE", $relatedUid); - } - } - } - } + if (!($jsContactRelation instanceof Relation)) { + continue; } - } - } - - /** - * This function maps the vCard X-MAIDENNAME (Roundcube-specific property) - * to the JSContact "audriga.eu/roundcube:maidenName" property - * - * @return string|null The "audriga.eu/roundcube:maidenName" property as a string - */ - public function getMaidenName() - { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { - return; - } - - $jsContactMaidenNameProperty = null; - // X-MAIDENNAME property mapping - // Note: The vCard X-MAIDENNAME property is Roundcube-specific - if (in_array("X-MAIDENNAME", $this->vCardChildren)) { - $vCardXMaidenNameProperty = $this->vCard->__get("X-MAIDENNAME"); - - if (isset($vCardXMaidenNameProperty)) { - $vCardXMaidenNamePropertyValue = $vCardXMaidenNameProperty->getValue(); - - if (isset($vCardXMaidenNamePropertyValue) && !empty($vCardXMaidenNamePropertyValue)) { - $jsContactMaidenNameProperty = $vCardXMaidenNamePropertyValue; - } + $jsContactRelationValues = $jsContactRelation->getRelation(); + if (!is_array($jsContactRelationValues)) { + continue; } - } - return $jsContactMaidenNameProperty; - } - - /** - * This function maps the JSContact "audriga.eu/roundcube:maidenName" property to the vCard X-MAIDENNAME property - * - * @param string|null $jsContactMaidenName The "audriga.eu/roundcube:maidenName" JSContact property as a string - */ - public function setXMaidenName($jsContactMaidenName) - { - if (!isset($jsContactMaidenName) || empty($jsContactMaidenName)) { - return; + if (!empty($jsContactRelationValues["manager"])) { + $this->vCard->add("X-MANAGER", $relatedUid); + } + if (!empty($jsContactRelationValues["assistant"])) { + $this->vCard->add("X-ASSISTANT", $relatedUid); + } + if (!empty($jsContactRelationValues["spouse"])) { + $this->vCard->add("X-SPOUSE", $relatedUid); + } } - - $this->vCard->add("X-MAIDENNAME", $jsContactMaidenName); } /** - * This function maps the vCard "ORG" property to the JSContact "organizations" property + * This function maps the JSContact "organizations" property to the vCard ORG property + * Per RFC 9555: ORG contains organization name + units in structured format + * For Roundcube compatibility: write X-DEPARTMENT for each unit * - * @return array|null - * The "organizations" JSContact property as a map of IDs to Organization objects + * @param ContactCard $card The ContactCard containing organizations */ - public function getOrganizations() + public function setOrganizations(ContactCard $card) { - // Before trying to map any vCard properties to any JSContact properties, - // check if the vCard has any properties at all and directly return if it doesn't have any - if (!AdapterUtil::checkVCardChildren($this->vCard)) { + $jsContactOrganizations = $card->getOrganizations(); + if (!is_array($jsContactOrganizations) || empty($jsContactOrganizations)) { return; } - $jsContactOrganizationsProperty = null; - - // ORG property mapping - if (in_array("ORG", $this->vCardChildren)) { - $vCardOrgProperties = $this->vCard->ORG; - - foreach ($vCardOrgProperties as $vCardOrgProperty) { - if (isset($vCardOrgProperty)) { - $vCardOrgPropertyValue = $vCardOrgProperty->getValue(); - - if (isset($vCardOrgPropertyValue) && !empty($vCardOrgPropertyValue)) { - $jsContactOrganization = new Organization(); - $jsContactOrganization->setAtType("Organization"); + foreach ($jsContactOrganizations as $id => $jsContactOrganization) { + if (!($jsContactOrganization instanceof Organization)) { + continue; + } - $jsContactOrganizationUnits = []; + $jsContactOrganizationName = $jsContactOrganization->getName(); + $jsContactOrganizationUnits = array(); - if (strpos($vCardOrgPropertyValue, ';') !== false) { - $vCardOrgPropertyValue = explode(';', $vCardOrgPropertyValue); - $jsContactOrganization->setName($vCardOrgPropertyValue[0]); - array_merge($jsContactOrganizationUnits, array_splice($vCardOrgPropertyValue, 0, 1)); - } else { - $jsContactOrganization->setName($vCardOrgPropertyValue); + $u = $jsContactOrganization->getUnits(); + if (is_array($u)) { + foreach ($u as $unitObj) { + if (is_object($unitObj)) { + $unitName = $unitObj->getName(); + if (is_string($unitName) && $unitName !== '') { + $jsContactOrganizationUnits[] = $unitName; } + } elseif (is_string($unitObj) && $unitObj !== '') { + $jsContactOrganizationUnits[] = $unitObj; + } + } + } - // If the "X-DEPARTMENT" vCard Roundcube-specific property exists, then map its value - // to the "units" property of an entry of the "organizations" JSContact property - // Moreover, here we assume we can have more than one X-DEPARTMENT properties per single vCard - if (in_array("X-DEPARTMENT", $this->vCardChildren)) { - $vCardXDepartmentProperties = $this->vCard->__get("X-DEPARTMENT"); - foreach ($vCardXDepartmentProperties as $vCardXDepartmentProperty) { - if (isset($vCardXDepartmentProperty) && !empty($vCardXDepartmentProperty)) { - $vCardXDepartmentPropertyValue = $vCardXDepartmentProperty->getValue(); - if ( - isset($vCardXDepartmentPropertyValue) - && !empty($vCardXDepartmentPropertyValue) - ) { - $jsContactOrganizationUnits[] = $vCardXDepartmentPropertyValue; - } - } - } - } + $parts = array_merge(array($jsContactOrganizationName), $jsContactOrganizationUnits); - if (isset($jsContactOrganizationUnits) && !empty($jsContactOrganizationUnits)) { - $jsContactOrganization->setUnits($jsContactOrganizationUnits); - } + $params = array(); + $types = Util::contextsToVcardTypeParam($jsContactOrganization); + if (!empty($types)) { + $params['TYPE'] = $types; + } - $jsContactOrganizationsProperty[md5($vCardOrgPropertyValue[0])] = $jsContactOrganization; - } + $params = Util::addPropIdParam($params, $id); - // Check if the currently unsupported vCard parameters ALTID and LANGUAGE are present - // If yes, then provide an error log with some information that they're not supported - if ( - isset($vCardOrgProperty['ALTID']) - && !empty($vCardOrgProperty['ALTID']) - ) { - $this->logger = Logger::getInstance(); - $this->logger->error( - "Currently unsupported vCard Parameter ALTID encountered - for vCard property ORG" - ); - } + $this->vCard->add('ORG', $parts, $params); - if ( - isset($vCardOrgProperty['LANGUAGE']) - && !empty($vCardOrgProperty['LANGUAGE']) - ) { - $this->logger = Logger::getInstance(); - $this->logger->error( - "Currently unsupported vCard Parameter LANGUAGE encountered - for vCard property ORG" - ); - } - } + // Write each unit as a separate X-DEPARTMENT property + foreach ($jsContactOrganizationUnits as $unit) { + $this->vCard->add('X-DEPARTMENT', $unit); } } - - return $jsContactOrganizationsProperty; } /** - * This function maps the JSContact "organizations" property to the vCard ORG property + * This function maps the JSContact "audriga.eu/roundcube:maidenName" property to the vCard X-MAIDENNAME property * - * @param array|null $jsContactOrganizations - * The "organizations" JSContact property as a map of strings to Organization objects + * @param ContactCard $card The ContactCard containing maiden name */ - public function setOrg($jsContactOrganizations) + public function setMaidenName(ContactCard $card) { - if (!isset($jsContactOrganizations) || empty($jsContactOrganizations)) { - return; - } - - foreach ($jsContactOrganizations as $id => $jsContactOrganization) { - if (isset($jsContactOrganization) && !empty($jsContactOrganization)) { - $jsContactOrganizationName = $jsContactOrganization->name; - $jsContactOrganizationUnits = $jsContactOrganization->units; + $jsContactMaidenName = $card->getProperty("audriga.eu/roundcube:maidenName"); - if (isset($jsContactOrganizationName) && !empty($jsContactOrganizationName)) { - $this->vCard->add("ORG", $jsContactOrganizationName); - - // If the "units" property of the "organizations" entry has values in it, then - // we write each value in it to a separate X-DEPARTMENT property in a given vCard - if (isset($jsContactOrganizationUnits) && !empty($jsContactOrganizationUnits)) { - $this->vCard->add("X-DEPARTMENT", $jsContactOrganizationUnits); - } - } + if ($jsContactMaidenName !== null && $jsContactMaidenName !== "") { + $value = is_string($jsContactMaidenName) ? $jsContactMaidenName : (string)$jsContactMaidenName; + if (trim($value) !== "") { + $this->addSingleProperty("X-MAIDENNAME", trim($value)); } } } diff --git a/src/mapper/JSCalendarICalendarMapper.php b/src/mapper/JSCalendarICalendarMapper.php index a8c34ff..e6dc493 100644 --- a/src/mapper/JSCalendarICalendarMapper.php +++ b/src/mapper/JSCalendarICalendarMapper.php @@ -5,11 +5,23 @@ use Exception; use Sabre\VObject; use OpenXPort\Jmap\Calendar\CalendarEvent; +use OpenXPort\Jmap\Calendar\PatchObject; use OpenXPort\Util\AdapterUtil; use OpenXPort\Util\JSCalendarICalendarAdapterUtil; +use OpenXPort\Adapter\JSCalendarICalendarAdapter; class JSCalendarICalendarMapper extends AbstractMapper { + /** + * Map from JMAP CalendarEvent objects (RFC 8984) + * to iCal data. + * https://datatracker.ietf.org/doc/draft-ietf-calext-jscalendar-icalendar/ + * + * @param array $jmapData + * @param JSCalendarICalendarAdapter $adapter + * + * @return array> + */ public function mapFromJmap($jmapData, $adapter) { $map = []; @@ -17,7 +29,8 @@ public function mapFromJmap($jmapData, $adapter) $adapter->resetICalEvent(); foreach ($jmapData as $creationId => $jsCalendarEvent) { - $adapter->setCalendarId($jsCalendarEvent->getCalendarId()); + $adapter->setCalendarId($jsCalendarEvent->getCalendarIds()); + $adapter->setMethod($jsCalendarEvent->getMethod()); // Map any properties of the event using the helper fucntion. $this->mapAllJmapPropertiesToICal($jsCalendarEvent, $adapter); @@ -50,6 +63,11 @@ public function mapFromJmap($jmapData, $adapter) continue; } + if ($this->isEmptyRecurrenceOverride($recurrenceOverride)) { + $masterEvent = $this->mapIncludedToRDate($adapter, $masterEvent, $recurrenceId); + continue; + } + $adapter->resetICalEvent(); // Map the properties of the recurrenceOverride to its corresponding VEVENT. @@ -78,7 +96,8 @@ public function mapFromJmap($jmapData, $adapter) protected function mapAllJmapPropertiesToICal($jsEvent, $adapter, $masterEvent = null) { if (is_null($jsEvent) || is_null($adapter)) { - // TODO: consider logging an error. + $logger = \OpenXPort\Util\Logger::getInstance(); + $logger->error("Cannot map iCal properties to JMAP: jmapEvent or adapter is null"); return; } @@ -90,7 +109,7 @@ protected function mapAllJmapPropertiesToICal($jsEvent, $adapter, $masterEvent = !is_null($masterEvent) && !is_null($masterEvent->getTimeZone()) ) { - $jsEvent->setTimeZone($masterEvent->getTimeZone()); + $jsEvent->setTimeZone($masterEvent->getTimeZone()); } // Similarly, to make sure that the override's DateTime values have the same format as @@ -101,7 +120,7 @@ protected function mapAllJmapPropertiesToICal($jsEvent, $adapter, $masterEvent = !is_null($masterEvent) && !is_null($masterEvent->getShowWithoutTime()) ) { - $jsEvent->setShowWithoutTime($masterEvent->getShowWithoutTime()); + $jsEvent->setShowWithoutTime($masterEvent->getShowWithoutTime()); } @@ -128,29 +147,43 @@ protected function mapAllJmapPropertiesToICal($jsEvent, $adapter, $masterEvent = $adapter->setPriority($jsEvent->getPriority()); $adapter->setAlerts($jsEvent->getAlerts()); + $adapter->setVLocations($jsEvent->getVLocations()); + $adapter->setVirtualLocations($jsEvent->getVirtualLocations()); $adapter->setParticipants($jsEvent->getParticipants()); + $adapter->setShowWithoutTime($jsEvent->getShowWithoutTime()); + + $adapter->setDuration($jsEvent->getDuration()); + $adapter->setGeo($jsEvent->getCoordinates()); + $adapter->setRelatedTo($jsEvent->getRelatedTo()); // Map any property which is stored as a link object in jsCal. Currently only attachment is supported. $splitLinkMap = JSCalendarICalendarAdapterUtil::splitJmapLinkMapIntoICalProperties( $jsEvent->getLinks() ); - $adapter->setAttachments($splitLinkMap["attachments"]); + $adapter->setAttachments( + is_array($splitLinkMap) && array_key_exists("attachments", $splitLinkMap) + ? $splitLinkMap["attachments"] + : null + ); + if (!is_null($splitLinkMap) && array_key_exists("urls", $splitLinkMap)) { + $urls = $splitLinkMap["urls"]; + if (!empty($urls)) { + $adapter->setUrl($urls[0]->getHref()); + } + } + $url = $jsEvent->getUrl(); + if (AdapterUtil::isSetNotNullAndNotEmpty($url)) { + $adapter->setUrl($url); + } + $adapter->setReplyTo($jsEvent->getReplyTo()); + + $adapter->setRequestStatus($jsEvent->getRequestStatus()); // Map any properties that are only found in the event itself. if (is_null($masterEvent)) { - // This mapper uses the updated recurrenceRules property, see: - // https://www.rfc-editor.org/rfc/rfc8984.html#name-recurrencerules - // If the given JSCalendar event only contains the recurrenceRule property, - // it will not be mapped. - if (!is_null($jsEvent->getRecurrenceRule()) && is_null($jsEvent->getRecurrenceRules())) { - throw new Exception( - "JSCalendar contains outdated 'RecurrenceRule' property which is not supported in this mapper." - ); - } - $adapter->setUid($jsEvent->getUid()); $adapter->setProdId($jsEvent->getProdId()); @@ -177,6 +210,25 @@ protected function mapExcludedToExDate($adapter, $masterEvent, $recurrenceId) return $masterEvent; } + /** + * Add an RDATE property to the master event for an included recurrence instance. + * + * @param JSCalendarICalendarAdapter $adapter + * @param VCalendar $masterEvent + * @param string $recurrenceId + * @return VCalendar + */ + protected function mapIncludedToRDate($adapter, $masterEvent, $recurrenceId) + { + $adapter->setICalEvent($masterEvent->serialize()); + + $adapter->setRDate($recurrenceId); + + $masterEvent = clone($adapter->getICalEvent()); + + return $masterEvent; + } + public function mapToJmap($data, $adapter) { $list = []; @@ -221,6 +273,11 @@ public function mapToJmap($data, $adapter) // Set the @type property here in order for the event to be recognised as a master event. $jsEvent->setType("Event"); + $methodProperty = $masterEvent["masterEvents"]["iCalendar"]->METHOD; + if (AdapterUtil::isSetNotNullAndNotEmpty($methodProperty)) { + $jsEvent->setMethod(strtolower($methodProperty->getValue())); + } + $this->mapAllICalPropertiesToJmap($jsEvent, $adapter); if ( @@ -228,7 +285,7 @@ public function mapToJmap($data, $adapter) is_array($masterEvent["masterEvents"]["oxpProperties"]) && array_key_exists("calendarId", $masterEvent["masterEvents"]["oxpProperties"]) ) { - $jsEvent->setCalendarId($masterEvent["masterEvents"]["oxpProperties"]["calendarId"]); + $jsEvent->setCalendarIds($masterEvent["masterEvents"]["oxpProperties"]["calendarId"]); } $jsEvent->setId($masterEvent["eventId"]); @@ -245,7 +302,7 @@ public function mapToJmap($data, $adapter) if (strcmp($modifiedExceptionUid, $masterEventUid) === 0) { $adapter->setICalEvent($modEx["modifiedExceptions"]->serialize()); - $jmapModifiedException = new CalendarEvent(); + $jmapModifiedException = new PatchObject(); // Modiified exceptions are are event that exclude the '@type', // 'excludeRecurrenceRules', 'method', 'privacy', 'prodId', 'recurrenceId', @@ -265,6 +322,13 @@ public function mapToJmap($data, $adapter) //Add the new modified occurrence to the ones already set in the JSCal event. $recurrenceIdValueDate = $modEx["modifiedExceptions"]->VEVENT->{'RECURRENCE-ID'}->getDateTime(); + // If showWithoutTime matches the master event, remove it from the override + if ( + $jsEvent->getShowWithoutTime() === $jmapModifiedException->getShowWithoutTime() || + (empty($jsEvent->getShowWithoutTime()) && empty($jmapModifiedException->getShowWithoutTime())) + ) { + $jmapModifiedException->setShowWithoutTime(null); + } $recurrenceIdOfModifiedException = date_format($recurrenceIdValueDate, "Y-m-d\TH:i:s"); $recurrenceOverrides[$recurrenceIdOfModifiedException] = $jmapModifiedException; @@ -296,12 +360,17 @@ private function mapAllICalPropertiesToJmap($jmapEvent, $adapter) $jmapEvent->setStart($adapter->getDTStart()); $jmapEvent->setDuration($adapter->getDuration()); - $jmapEvent->setTimezone($adapter->getTimezone()); + $jmapEvent->setTimeZone($adapter->getTimeZone()); + $jmapEvent->setShowWithoutTime($adapter->getShowWithoutTime()); $jmapEvent->setKeywords($adapter->getCategories()); $jmapEvent->setLocations($adapter->getLocation()); + $jmapEvent->setVLocations($adapter->getVLocations()); + $jmapEvent->setCoordinates($adapter->getGeo()); + $jmapEvent->setVirtualLocations($adapter->getVirtualLocations()); $jmapEvent->setFreeBusyStatus($adapter->getFreeBusy()); + $jmapEvent->setStatus($adapter->getStatus()); $jmapEvent->setColor($adapter->getColor()); $jmapEvent->setPriority($adapter->getPriority()); @@ -320,6 +389,18 @@ private function mapAllICalPropertiesToJmap($jmapEvent, $adapter) $attachments = $adapter->getAttachments(); $jmapLinks = array_merge($jmapLinks, $attachments ? $attachments : []); + $url = $adapter->getUrl(); + if (!is_null($url)) { + $urlLink = new \OpenXPort\Jmap\Calendar\Link(); + $urlLink->setType("Link"); + $urlLink->setHref($url); + array_push($jmapLinks, $urlLink); + + $jmapEvent->setUrl($url); + } + $jmapEvent->setReplyTo($adapter->getReplyTo()); + $jmapEvent->setRequestStatus($adapter->getRequestStatus()); + // Create indices for the JMAP link map. Otherwise, the objects would be // stored in an array. $jmapLinkIndices = array_map( @@ -332,32 +413,61 @@ function ($i) { $jmapLinks = array_combine($jmapLinkIndices, $jmapLinks); - // TODO: Add handling of attachments in recurrence overrides. - // See: https://www.ietf.org/archive/id/draft-ietf-calext-jscalendar-icalendar-07.html#name-recurring-event-with-attach + // Attachments are mapped for both master events and recurrence overrides via the + // generic links <-> ATTACH conversion path. $jmapEvent->setLinks($jmapLinks); // Map the properties that are strictly set in master event. - if (strcmp($jmapEvent->getType(), "Event") === 0) { - $jmapEvent->setShowWithoutTime($adapter->getShowWithoutTime()); + if ($jmapEvent instanceof CalendarEvent) { $jmapEvent->setUid($adapter->getUid()); $jmapEvent->setProdId($adapter->getProdId()); $jmapEvent->setPrivacy($adapter->getClass()); $jmapEvent->setRecurrenceRules($adapter->getRRule()); + $jmapEvent->setRelatedTo($adapter->getRelatedTo()); - if (!is_null($adapter->getExDates())) { - $excludedOverrides = []; + $recurrenceOverrides = []; + if (!is_null($adapter->getExDates())) { foreach ($adapter->getExDates() as $exDate) { - $excludedOverride = new CalendarEvent(); + $excludedOverride = new PatchObject(); $excludedOverride->setExcluded(true); - $excludedOverrides[$exDate] = $excludedOverride; + $recurrenceOverrides[$exDate] = $excludedOverride; } + } - $jmapEvent->setRecurrenceOverrides($excludedOverrides); + if (!is_null($adapter->getRDates())) { + foreach ($adapter->getRDates() as $rDate) { + if (!array_key_exists($rDate, $recurrenceOverrides)) { + $recurrenceOverrides[$rDate] = new PatchObject(); + } + } + } + + if (!empty($recurrenceOverrides)) { + $jmapEvent->setRecurrenceOverrides($recurrenceOverrides); } } } + + /** + * Check if a recurrence override is empty. + * + * @param PatchObject|array|null $recurrenceOverride + * @return bool True if empty + */ + protected function isEmptyRecurrenceOverride($recurrenceOverride) + { + if (is_null($recurrenceOverride)) { + return true; + } + + if ($recurrenceOverride instanceof PatchObject) { + return $recurrenceOverride->isEmpty(); + } + + return empty((array) $recurrenceOverride); + } } diff --git a/src/mapper/JSContactVCardMapper.php b/src/mapper/JSContactVCardMapper.php index c25dfe8..9111f40 100644 --- a/src/mapper/JSContactVCardMapper.php +++ b/src/mapper/JSContactVCardMapper.php @@ -3,91 +3,89 @@ namespace OpenXPort\Mapper; use InvalidArgumentException; -use OpenXPort\Jmap\JSContact\Card; +use OpenXPort\Adapter\JSContactVCardAdapter; +use OpenXPort\Jmap\JSContact\ContactCard; use OpenXPort\Util\Logger; class JSContactVCardMapper extends AbstractMapper { protected $logger; + /** + * Map from JMAP ContactCard objects (RFC 9553) + * to vCard data. + * https://datatracker.ietf.org/doc/rfc9555/ + * + * @param array $jmapData + * @param JSContactVCardAdapter $adapter + * + * @return array> + */ public function mapFromJmap($jmapData, $adapter) { $map = []; foreach ($jmapData as $creationId => $jsContactCard) { try { + if (!($jsContactCard instanceof ContactCard)) { + $card = new ContactCard(); + foreach (get_object_vars($jsContactCard) as $key => $value) { + $setter = 'set' . ucfirst($key); + $card->$setter($value); + } + $jsContactCard = $card; + } + // start with a clean vCard each time $adapter->reset(); - $adapter->setAddressBookId($jsContactCard->addressBookId); - - // online deprecated - // TODO only onlineServices conversion implemented for newest spec - if (!empty($jsContactCard->online)) { - $adapter->setSource($jsContactCard->online); - $adapter->setImpp($jsContactCard->online); - $adapter->setLogo($jsContactCard->online); - $adapter->setContactUri($jsContactCard->online); - $adapter->setOrgDirectory($jsContactCard->online); - $adapter->setSound($jsContactCard->online); - $adapter->setUrl($jsContactCard->online); - $adapter->setKey($jsContactCard->online); - $adapter->setFbUrl($jsContactCard->online); - $adapter->setCalAdrUri($jsContactCard->online); - $adapter->setCalUri($jsContactCard->online); + // Set addressBookId from the card + $addressBookIds = $jsContactCard->getAddressBookIds(); + if (is_array($addressBookIds) && !empty($addressBookIds)) { + $adapter->setAddressBookId($addressBookIds[0]); } - // onlineServices - if (!empty($jsContactCard->onlineServices)) { - $adapter->setImppFromServices($jsContactCard->onlineServices); - $adapter->setSocialFromServices($jsContactCard->onlineServices); + // Map all properties from JSContact to vCard + $adapter->setUid($jsContactCard); + $adapter->setKind($jsContactCard); + $adapter->setFn($jsContactCard); + $adapter->setName($jsContactCard); + $adapter->setNickname($jsContactCard); + $adapter->setMedia($jsContactCard); + $adapter->setAnniversaries($jsContactCard); + $adapter->setGramGender($jsContactCard); + $adapter->setPronouns($jsContactCard); + $adapter->setAddresses($jsContactCard); + $adapter->setPhones($jsContactCard); + $adapter->setEmails($jsContactCard); + $adapter->setPreferredLanguages($jsContactCard); + $adapter->setTitles($jsContactCard); + $adapter->setOrganizations($jsContactCard); + $adapter->setRelatedTo($jsContactCard); + $adapter->setPersonalInfo($jsContactCard); + $adapter->setKeywords($jsContactCard); + $adapter->setNotes($jsContactCard); + $adapter->setProdId($jsContactCard); + $adapter->setUpdated($jsContactCard); + $adapter->setCreated($jsContactCard); + $adapter->setLanguage($jsContactCard); + $adapter->setOnlineServices($jsContactCard); + $adapter->setDirectories($jsContactCard); + $adapter->setLinks($jsContactCard); + $adapter->setCryptoKeys($jsContactCard); + $adapter->setSchedulingAddresses($jsContactCard); + $adapter->setCalendars($jsContactCard); + $adapter->setMembers($jsContactCard); + + // Get the serialized vCard and build result + $backendContact = $adapter->getVCard(); + $result = array("vCard" => $backendContact); + + // Add addressBookId to oxpProperties + if (is_array($addressBookIds) && !empty($addressBookIds)) { + $result["oxpProperties"]["addressBookId"] = $addressBookIds[0]; } - $adapter->setKind($jsContactCard->kind); - - $adapter->setFN($jsContactCard->fullName); - $adapter->setN($jsContactCard->name); - $adapter->setNickname($jsContactCard->nickNames); - - $adapter->setPhoto($jsContactCard->photos); - - $adapter->setBDay($jsContactCard->anniversaries); - $adapter->setBirthPlace($jsContactCard->anniversaries); - $adapter->setDeathDate($jsContactCard->anniversaries); - $adapter->setDeathPlace($jsContactCard->anniversaries); - $adapter->setAnniversary($jsContactCard->anniversaries); - - $adapter->setGender($jsContactCard->speakToAs); - - $adapter->setADR($jsContactCard->addresses); - $adapter->setTZ($jsContactCard->addresses); - - $adapter->setTel($jsContactCard->phones); - - $adapter->setEmail($jsContactCard->emails); - - $adapter->setLang($jsContactCard->preferredContactLanguages); - - $adapter->setTitle($jsContactCard->titles); - - $adapter->setOrg($jsContactCard->organizations); - - $adapter->setRelated($jsContactCard->relatedTo); - - $adapter->setExpertise($jsContactCard->personalInfo); - $adapter->setHobby($jsContactCard->personalInfo); - $adapter->setInterest($jsContactCard->personalInfo); - - $adapter->setCategories($jsContactCard->categories); - - $adapter->setNote($jsContactCard->notes); - - $adapter->setProdId($jsContactCard->prodId); - - $adapter->setRev($jsContactCard->updated); - - $adapter->deriveFN($jsContactCard->name); - - array_push($map, array($creationId => $adapter->getAsHash())); + array_push($map, array($creationId => $result)); } catch (InvalidArgumentException $e) { $this->logger = Logger::getInstance(); $this->logger->error($e->getMessage()); @@ -101,53 +99,72 @@ public function mapFromJmap($jmapData, $adapter) return $map; } + /** + * Map from vCard data to JMAP ContactCard objects (RFC 9553). + * + * @param array $data + * @param JSContactVCardAdapter $adapter + * + * @return ContactCard[] + */ public function mapToJmap($data, $adapter) { $list = []; foreach ($data as $contactId => $cHash) { $adapter->reset(); - $adapter->setFromHash($cHash); - $jsContactCard = new Card(); + // Handle vCard payload - either in array or direct + $vCardPayload = is_array($cHash) && array_key_exists("vCard", $cHash) + ? $cHash["vCard"] + : $cHash; + $adapter->setVCard($vCardPayload); + $jsContactCard = new ContactCard(); + + // Handle oxpProperties if present if ( + is_array($cHash) && array_key_exists("oxpProperties", $cHash) && array_key_exists("addressBookId", $cHash["oxpProperties"]) ) { - $jsContactCard->setAddressBookId($cHash["oxpProperties"]["addressBookId"]); + $jsContactCard->setAddressBookIds([$cHash["oxpProperties"]["addressBookId"]]); } $jsContactCard->setAtType("Card"); - $jsContactCard->setId($contactId); - $jsContactCard->setOnline($adapter->getOnline()); - $jsContactCard->setOnlineServices($adapter->getOnlineServices()); - $jsContactCard->setKind($adapter->getKind()); - $jsContactCard->setFullName($adapter->getFullName()); - $jsContactCard->setName($adapter->getName()); - $jsContactCard->setNickNames($adapter->getNickNames()); - $jsContactCard->setPhotos($adapter->getPhotos()); - $jsContactCard->setAnniversaries($adapter->getAnniversaries()); - $jsContactCard->setSpeakToAs($adapter->getSpeakToAs()); - $jsContactCard->setAddresses($adapter->getAddresses()); - $jsContactCard->setPhones($adapter->getPhones()); - $jsContactCard->setEmails($adapter->getEmails()); - $jsContactCard->setPreferredContactLanguages($adapter->getPreferredContactLanguages()); - $jsContactCard->setTitles($adapter->getTitles()); - $jsContactCard->setOrganizations($adapter->getOrganizations()); - $jsContactCard->setRelatedTo($adapter->getRelatedTo()); - $jsContactCard->setPersonalInfo($adapter->getPersonalInfo()); - $jsContactCard->setCategories($adapter->getCategories()); - $jsContactCard->setNotes($adapter->getNotes()); - $jsContactCard->setProdId($adapter->getProdId()); - $jsContactCard->setUpdated($adapter->getUpdated()); - - // Currently assume uid = id in OXP Core - // WARNING: This will disregard UID from vCards - // replace with the following to support UIDs: - // $jsContactCard->setUid($adapter->getUid()); $jsContactCard->setUid($contactId); + // Map all properties from vCard to JSContact + $adapter->getUid($jsContactCard); + $adapter->getKind($jsContactCard); + $adapter->getName($jsContactCard); + $adapter->getNickname($jsContactCard); + $adapter->getMedia($jsContactCard); + $adapter->getAnniversaries($jsContactCard); + $adapter->getGramGender($jsContactCard); + $adapter->getPronouns($jsContactCard); + $adapter->getAddresses($jsContactCard); + $adapter->getPhones($jsContactCard); + $adapter->getEmails($jsContactCard); + $adapter->getPreferredLanguages($jsContactCard); + $adapter->getTitles($jsContactCard); + $adapter->getOrganizations($jsContactCard); + $adapter->getRelatedTo($jsContactCard); + $adapter->getPersonalInfo($jsContactCard); + $adapter->getKeywords($jsContactCard); + $adapter->getNotes($jsContactCard); + $adapter->getProdId($jsContactCard); + $adapter->getUpdated($jsContactCard); + $adapter->getCreated($jsContactCard); + $adapter->getLanguage($jsContactCard); + $adapter->getOnlineServices($jsContactCard); + $adapter->getDirectories($jsContactCard); + $adapter->getLinks($jsContactCard); + $adapter->getCryptoKeys($jsContactCard); + $adapter->getSchedulingAddresses($jsContactCard); + $adapter->getCalendars($jsContactCard); + $adapter->getMembers($jsContactCard); + array_push($list, $jsContactCard); } diff --git a/src/mapper/RoundcubeJSContactVCardMapper.php b/src/mapper/RoundcubeJSContactVCardMapper.php index 31a3a2f..15d0198 100644 --- a/src/mapper/RoundcubeJSContactVCardMapper.php +++ b/src/mapper/RoundcubeJSContactVCardMapper.php @@ -3,84 +3,74 @@ namespace OpenXPort\Mapper; use InvalidArgumentException; -use OpenXPort\Jmap\JSContact\Audriga\Card; +use OpenXPort\Adapter\RoundcubeJSContactVCardAdapter; +use OpenXPort\Jmap\JSContact\ContactCard; use OpenXPort\Util\Logger; use Sabre\VObject\ParseException; +/** + * Roundcube-specific mapper for RFC 9553 ContactCard <-> vCard conversion. + * + * Extends JSContactVCardMapper to handle Roundcube's custom X-properties: + * - X-MAIDENNAME (maiden name) + * - X-ANNIVERSARY (anniversary date) + * - X-GENDER (grammatical gender) + * - X-DEPARTMENT (organization units) + * - X-AIM, X-ICQ, X-MSN, X-YAHOO, X-JABBER, X-SKYPE-USERNAME (instant messaging) + * - X-MANAGER, X-ASSISTANT, X-SPOUSE (relations) + */ class RoundcubeJSContactVCardMapper extends JSContactVCardMapper { + protected $logger; + + /** + * Map from JMAP ContactCard objects to vCard with Roundcube extensions. + * + * @param array $jmapData + * @param RoundcubeJSContactVCardAdapter $adapter + * + * @return array> + */ public function mapFromJmap($jmapData, $adapter) { $map = []; foreach ($jmapData as $creationId => $jsContactCard) { try { - $adapter->setSource($jsContactCard->online); - $adapter->setImpp($jsContactCard->online); - $adapter->setLogo($jsContactCard->online); - $adapter->setContactUri($jsContactCard->online); - $adapter->setOrgDirectory($jsContactCard->online); - $adapter->setSound($jsContactCard->online); - $adapter->setUrl($jsContactCard->online); - $adapter->setKey($jsContactCard->online); - $adapter->setFbUrl($jsContactCard->online); - $adapter->setCalAdrUri($jsContactCard->online); - $adapter->setCalUri($jsContactCard->online); - $adapter->setXAim($jsContactCard->online); - $adapter->setXIcq($jsContactCard->online); - $adapter->setXMsn($jsContactCard->online); - $adapter->setXYahoo($jsContactCard->online); - $adapter->setXJabber($jsContactCard->online); - $adapter->setXSkypeUsername($jsContactCard->online); - - $adapter->setKind($jsContactCard->kind); - - $adapter->setFN($jsContactCard->fullName); - $adapter->setN($jsContactCard->name); - $adapter->setNickname($jsContactCard->nickNames); - - $adapter->setPhoto($jsContactCard->photos); - - $adapter->setBDay($jsContactCard->anniversaries); - $adapter->setBirthPlace($jsContactCard->anniversaries); - $adapter->setDeathDate($jsContactCard->anniversaries); - $adapter->setDeathPlace($jsContactCard->anniversaries); - $adapter->setAnniversary($jsContactCard->anniversaries); - $adapter->setXAnniversary($jsContactCard->anniversaries); - - $adapter->setXGender($jsContactCard->speakToAs); - - $adapter->setADR($jsContactCard->addresses); - $adapter->setTZ($jsContactCard->addresses); - - $adapter->setTel($jsContactCard->phones); - - $adapter->setEmail($jsContactCard->emails); - - $adapter->setLang($jsContactCard->preferredContactLanguages); - - $adapter->setTitle($jsContactCard->titles); - - $adapter->setOrg($jsContactCard->organizations); - - $adapter->setRelated($jsContactCard->relatedTo); - $adapter->setXManager($jsContactCard->relatedTo); - $adapter->setXAssistant($jsContactCard->relatedTo); - $adapter->setXSpouse($jsContactCard->relatedTo); - - $adapter->setExpertise($jsContactCard->personalInfo); - $adapter->setHobby($jsContactCard->personalInfo); - $adapter->setInterest($jsContactCard->personalInfo); - - $adapter->setCategories($jsContactCard->categories); - - $adapter->setNote($jsContactCard->notes); - - $adapter->setProdId($jsContactCard->prodId); - - $adapter->setRev($jsContactCard->updated); - - $adapter->setXMaidenName($jsContactCard->{"audriga.eu/roundcube:maidenName"}); + $adapter->reset(); + + $adapter->setAddressBookId($jsContactCard->getAddressBookIds()); + $adapter->setUid($jsContactCard); + $adapter->setKind($jsContactCard); + $adapter->setFn($jsContactCard); + $adapter->setName($jsContactCard); + $adapter->setNickname($jsContactCard); + $adapter->setMedia($jsContactCard); + $adapter->setAnniversaries($jsContactCard); + $adapter->setGramGender($jsContactCard); + $adapter->setPronouns($jsContactCard); + $adapter->setAddresses($jsContactCard); + $adapter->setPhones($jsContactCard); + $adapter->setEmails($jsContactCard); + $adapter->setPreferredLanguages($jsContactCard); + $adapter->setTitles($jsContactCard); + $adapter->setOrganizations($jsContactCard); + $adapter->setRelatedTo($jsContactCard); + $adapter->setPersonalInfo($jsContactCard); + $adapter->setKeywords($jsContactCard); + $adapter->setNotes($jsContactCard); + $adapter->setProdId($jsContactCard); + $adapter->setUpdated($jsContactCard); + $adapter->setCreated($jsContactCard); + $adapter->setLanguage($jsContactCard); + $adapter->setOnlineServices($jsContactCard); + $adapter->setDirectories($jsContactCard); + $adapter->setLinks($jsContactCard); + $adapter->setCryptoKeys($jsContactCard); + $adapter->setSchedulingAddresses($jsContactCard); + $adapter->setCalendars($jsContactCard); + $adapter->setMembers($jsContactCard); + $adapter->setMaidenName($jsContactCard); array_push($map, array($creationId => $adapter->getVCard())); } catch (InvalidArgumentException $e) { @@ -96,11 +86,21 @@ public function mapFromJmap($jmapData, $adapter) return $map; } + /** + * Map from vCard to JMAP ContactCard objects with Roundcube extensions. + * + * @param array $data + * @param RoundcubeJSContactVCardAdapter $adapter + * + * @return ContactCard[] + */ public function mapToJmap($data, $adapter) { $list = []; foreach ($data as $contactId => $vCard) { + $adapter->reset(); + // Try setting the vCard from the received String. If it cannot be parsed, add // more info to the thrown ParseException. try { @@ -119,40 +119,45 @@ public function mapToJmap($data, $adapter) continue; } - $jsContactCard = new Card(); + $jsContactCard = new ContactCard(); + $jsContactCard->setAtType("Card"); - $jsContactCard->setId($contactId); - $jsContactCard->setOnline($adapter->getOnline()); - $jsContactCard->setKind($adapter->getKind()); - $jsContactCard->setFullName($adapter->getFullName()); - $jsContactCard->setName($adapter->getName()); - $jsContactCard->setNickNames($adapter->getNickNames()); - $jsContactCard->setPhotos($adapter->getPhotos()); - $jsContactCard->setAnniversaries($adapter->getAnniversaries()); - $jsContactCard->setSpeakToAs($adapter->getSpeakToAs()); - $jsContactCard->setAddresses($adapter->getAddresses()); - $jsContactCard->setPhones($adapter->getPhones()); - $jsContactCard->setEmails($adapter->getEmails()); - $jsContactCard->setPreferredContactLanguages($adapter->getPreferredContactLanguages()); - $jsContactCard->setTitles($adapter->getTitles()); - $jsContactCard->setOrganizations($adapter->getOrganizations()); - $jsContactCard->setRelatedTo($adapter->getRelatedTo()); - $jsContactCard->setPersonalInfo($adapter->getPersonalInfo()); - $jsContactCard->setCategories($adapter->getCategories()); - $jsContactCard->setNotes($adapter->getNotes()); - $jsContactCard->setProdId($adapter->getProdId()); - $jsContactCard->setUpdated($adapter->getUpdated()); - - // Currently assume uid = id in OXP Core - // WARNING: This will disregard UID from vCards - // replace with the following to support UIDs: - // $jsContactCard->setUid($adapter->getUid()); $jsContactCard->setUid($contactId); + $adapter->getUid($jsContactCard); + $adapter->getKind($jsContactCard); + $adapter->getName($jsContactCard); + $adapter->getNickname($jsContactCard); + $adapter->getMedia($jsContactCard); + $adapter->getAnniversaries($jsContactCard); + $adapter->getGramGender($jsContactCard); + $adapter->getPronouns($jsContactCard); + $adapter->getAddresses($jsContactCard); + $adapter->getPhones($jsContactCard); + $adapter->getEmails($jsContactCard); + $adapter->getPreferredLanguages($jsContactCard); + $adapter->getTitles($jsContactCard); + $adapter->getOrganizations($jsContactCard); + $adapter->getRelatedTo($jsContactCard); + $adapter->getPersonalInfo($jsContactCard); + $adapter->getKeywords($jsContactCard); + $adapter->getNotes($jsContactCard); + $adapter->getProdId($jsContactCard); + $adapter->getUpdated($jsContactCard); + $adapter->getCreated($jsContactCard); + $adapter->getLanguage($jsContactCard); + $adapter->getOnlineServices($jsContactCard); + $adapter->getDirectories($jsContactCard); + $adapter->getLinks($jsContactCard); + $adapter->getCryptoKeys($jsContactCard); + $adapter->getSchedulingAddresses($jsContactCard); + $adapter->getCalendars($jsContactCard); + $adapter->getMembers($jsContactCard); + $adapter->getMaidenName($jsContactCard); + // Map Roundcube-specific vCard properties to audriga-defined JSContact properties // Note: X-DEPARTMENT is currently mapped to "organizations" // See RoundcubeJSContactVCardAdapter's getOrganizations() method for more info - $jsContactCard->setMaidenName($adapter->getMaidenName()); array_push($list, $jsContactCard); } diff --git a/src/util/JSCalendarICalendarAdapterUtil.php b/src/util/JSCalendarICalendarAdapterUtil.php index 679c1d9..84eeb8f 100644 --- a/src/util/JSCalendarICalendarAdapterUtil.php +++ b/src/util/JSCalendarICalendarAdapterUtil.php @@ -424,7 +424,6 @@ public static function convertFromICalUntilToJmapUntil($until) public static function convertFromJmapUntilToICalUntil($until, $dtStart) { - //TODO: Figure out how to add the timezone difference to the value. if (!AdapterUtil::isSetNotNullAndNotEmpty($until)) { return null; } @@ -441,6 +440,7 @@ public static function convertFromJmapUntilToICalUntil($until, $dtStart) * UNTIL rule part MUST be specified as a date with UTC time." */ $iCalFormat = "Ymd\THis"; + $needsUtcConversion = false; if ( AdapterUtil::isSetNotNullAndNotEmpty($dtStart) @@ -450,6 +450,7 @@ public static function convertFromJmapUntilToICalUntil($until, $dtStart) ) ) { $iCalFormat = "Ymd\THis\Z"; + $needsUtcConversion = true; } @@ -465,6 +466,15 @@ public static function convertFromJmapUntilToICalUntil($until, $dtStart) return null; } + // Convert to UTC if DTSTART has timezone or is UTC + if ($needsUtcConversion && AdapterUtil::isSetNotNullAndNotEmpty($dtStart)) { + $dtStartTimeZone = $dtStart->getDateTime()->getTimeZone(); + if (AdapterUtil::isSetNotNullAndNotEmpty($dtStartTimeZone)) { + $jmapUntilDate->setTimezone($dtStartTimeZone); + } + $jmapUntilDate->setTimezone(new \DateTimeZone('UTC')); + } + $iCalUntil = date_format($jmapUntilDate, $iCalFormat); return $iCalUntil; @@ -673,6 +683,8 @@ public static function convertFromJmapScheduleStatusToICalScheduleStatus($schedu public static function splitJmapLinkMapIntoICalProperties($linkMap) { + $linkMap = self::normalizeJmapLinks($linkMap); + if ( !AdapterUtil::isSetNotNullAndNotEmpty($linkMap) || !is_array($linkMap) @@ -710,6 +722,36 @@ public static function splitJmapLinkMapIntoICalProperties($linkMap) return $splitLinkMap; } + /** + * Convert a JMAP link map so that all entries are Link objects. + */ + public static function normalizeJmapLinks($linkMap) + { + if (!AdapterUtil::isSetNotNullAndNotEmpty($linkMap)) { + return null; + } + + if ($linkMap instanceof \stdClass) { + $linkMap = (array) $linkMap; + } + + if (!is_array($linkMap)) { + return null; + } + + $normalizedLinks = []; + + foreach ($linkMap as $id => $link) { + $normalizedLink = \OpenXPort\Jmap\Calendar\Link::fromMixed($link); + + if (!is_null($normalizedLink)) { + $normalizedLinks[$id] = $normalizedLink; + } + } + + return $normalizedLinks; + } + public static function extractMediaTypeFromDataUrlMetaDataString($metaData) { // Data URLs use a "/" to show the [type]/[subtype] of their data. diff --git a/src/util/JSContactVCardAdapterUtil.php b/src/util/JSContactVCardAdapterUtil.php index bb392ed..c5edf58 100644 --- a/src/util/JSContactVCardAdapterUtil.php +++ b/src/util/JSContactVCardAdapterUtil.php @@ -2,7 +2,9 @@ namespace OpenXPort\Util; -use InvalidArgumentException; +use OpenXPort\Jmap\JSContact\Address; +use OpenXPort\Jmap\JSContact\Media; +use OpenXPort\Util\AdapterUtil; /** * Utility class used by JSContactVCardAdapters to convert property values. @@ -11,159 +13,824 @@ class JSContactVCardAdapterUtil { protected static $logger; - public static function convertFromNameToN($jsContactName) - { - if (is_null($jsContactName)) { - return array(); - } - - $jsContactNameComponents = $jsContactName->components; - - $vCardPrefix = null; - $vCardGivenName = null; - $vCardFamilyName = null; - $vCardAdditionalName = null; - $vCardSuffix = null; - - if (isset($jsContactNameComponents) && !empty($jsContactNameComponents)) { - foreach ($jsContactNameComponents as $jsContactNameComponent) { - if (isset($jsContactNameComponent) && !empty($jsContactNameComponent)) { - $jsContactNameComponentType = $jsContactNameComponent->type; - if (isset($jsContactNameComponentType) && !empty($jsContactNameComponentType)) { - $jsContactNameComponentValue = $jsContactNameComponent->value; - - if (isset($jsContactNameComponentValue) && !empty($jsContactNameComponentValue)) { - switch ($jsContactNameComponentType) { - case 'prefix': - $vCardPrefix = $jsContactNameComponentValue; - break; - - case 'given': - $vCardGivenName = $jsContactNameComponentValue; - break; - - case 'surname': - $vCardFamilyName = $jsContactNameComponentValue; - break; - - // TODO remove once we move to actual JSContact RFC - case 'additional': - $vCardAdditionalName = $jsContactNameComponentValue; - break; - - case 'middle': - $vCardAdditionalName = $jsContactNameComponentValue; - break; - - case 'suffix': - $vCardSuffix = $jsContactNameComponentValue; - break; - - default: - throw new InvalidArgumentException("Unknown value for the \"type\" property - of object NameComponent encountered during conversion to the vCard - N property. Encountered value is: " . $jsContactNameComponentType); - break; - } - } + /** + * Converts vCard TYPE parameter to JSContact contexts + * "home" becomes "private" and "work" stays "work" + */ + public static function vCardTypeParamToContexts($prop) + { + $contexts = array(); + + if (isset($prop['TYPE'])) { + $types = $prop['TYPE']->getParts(); + if (is_array($types)) { + foreach ($types as $t) { + $t = strtolower(trim((string) $t)); + if ($t === 'home') { + $contexts['private'] = true; + } elseif ($t === 'work') { + $contexts['work'] = true; } } } } - return array($vCardFamilyName, $vCardGivenName, $vCardAdditionalName, $vCardPrefix, $vCardSuffix); + + return $contexts; + } + + /** + * Converts vCard PREF parameter to integer + */ + public static function vCardPrefParamToInt($prop) + { + if (!isset($prop['PREF'])) { + return null; + } + + $raw = trim((string) $prop['PREF']); + if ($raw === '' || !ctype_digit($raw)) { + return null; + } + + $n = (int) $raw; + return $n > 0 ? $n : null; + } + + /** + * Converts JSContact contexts to vCard TYPE parameter values + * "private" becomes "home" and "work" stays "work" + */ + public static function contextsToVcardTypeParam($obj) + { + $types = array(); + + if (is_object($obj)) { + $ctx = $obj->getContexts(); + if (is_array($ctx)) { + if (!empty($ctx['private'])) { + $types[] = 'home'; + } + if (!empty($ctx['work'])) { + $types[] = 'work'; + } + } + } + + return $types; + } + + /** + * Converts JSContact preference to vCard PREF parameter string + */ + public static function prefToVcardParam($obj) + { + if (!is_object($obj)) { + return null; + } + + $pref = $obj->getPref(); + if ($pref === null) { + return null; + } + + $pref = (int) $pref; + return $pref > 0 ? (string) $pref : null; + } + + /** + * Applies context and preference from vCard property to JSContact object + */ + public static function applyCommonContextAndPref($obj, $prop) + { + if (!is_object($obj) || $prop === null) { + return; + } + + $ctx = self::vCardTypeParamToContexts($prop); + if (!empty($ctx)) { + $obj->setContexts($ctx); + } + + $pref = self::vCardPrefParamToInt($prop); + if ($pref !== null) { + $obj->setPref($pref); + } + } + + /** + * Builds parameters with TYPE and PREF from JSContact object + */ + public static function buildContextPrefParams($obj, array $params = array()) + { + $types = self::contextsToVcardTypeParam($obj); + if (!empty($types)) { + $params['TYPE'] = $types; + } + + $pref = self::prefToVcardParam($obj); + if ($pref !== null) { + $params['PREF'] = $pref; + } + + return $params; + } + + /** + * Converts Y-m-d date to vCard date format (Ymd) + */ + public static function parseDateToVcardDate($value) + { + if (!is_string($value) || trim($value) === '' || $value === '0000-00-00') { + return null; + } + + return AdapterUtil::parseDateTime($value, 'Y-m-d', 'Ymd'); + } + + /** + * Converts JSContact UTC timestamp to vCard TIMESTAMP format (YmdTHisZ) + */ + public static function parseDateTimeToVcardTimestamp($value) + { + if (!is_string($value) || trim($value) === '') { + return null; + } + + return AdapterUtil::parseDateTime($value, 'Y-m-d\TH:i:s\Z', 'Ymd\THis\Z'); + } + + /** + * Converts vCard TIMESTAMP to JSContact UTC timestamp + */ + public static function parseTimestampDateTime($value) + { + if (!is_string($value) || trim($value) === '') { + return null; + } + + return AdapterUtil::parseDateTime($value, 'Ymd\THis\Z', 'Y-m-d\TH:i:s\Z'); + } + + /** + * Converts various vCard date/time formats to JSContact UTC + */ + public static function parseDateTimeToJscontactUtc($value) + { + if (!is_string($value) || trim($value) === '') { + return null; + } + + $value = trim($value); + + $formats = array( + 'Ymd\THis\Z', + 'Y-m-d\TH:i:s\Z', + 'Ymd\THis', + 'Y-m-d\TH:i:s', + 'Ymd', + 'Y-m-d', + ); + + foreach ($formats as $from) { + $parsed = AdapterUtil::parseDateTime($value, $from, 'Y-m-d\TH:i:s\Z'); + if ($parsed !== null) { + return $parsed; + } + } + + return null; + } + + /** + * Extracts PROP-ID parameter from vCard property + */ + public static function getPropId($prop) + { + if (!isset($prop['PROP-ID'])) { + return null; + } + + $value = trim((string) $prop['PROP-ID']); + return $value !== '' ? $value : null; + } + + /** + * Adds PROP-ID to params if the map key is suitable + */ + public static function addPropIdParam(array $params, $mapKey) + { + if (!is_string($mapKey)) { + return $params; + } + + $mapKey = trim($mapKey); + if ($mapKey === '') { + return $params; + } + + // Skip auto-generated keys + if (preg_match('/^(n|pr|o|t|e|p|os|lp|m|d|l|k|sa|a)\d+$/', $mapKey)) { + return $params; + } + + // Skip MD5 hashes + if (preg_match('/^[a-f0-9]{32}$/i', $mapKey)) { + return $params; + } + + $params['PROP-ID'] = $mapKey; + + return $params; + } + + /** + * Gets map key from PROP-ID or falls back to provided key + */ + public static function getMapKeyFromProp($prop, $fallback) + { + $propId = self::getPropId($prop); + return $propId !== null ? $propId : $fallback; + } + + /** + * Gets map key from PROP-ID, INDEX, or generates from value hash + */ + public static function getMapKeyFromPropValue($prop, $value, $fallbackPrefix, &$index, array $existingMap = array()) + { + $key = self::getPropId($prop); + + if ($key === null && isset($prop['INDEX'])) { + $indexValue = trim((string) $prop['INDEX']); + if ($indexValue !== '') { + $key = $indexValue; + } + } + + if ($key === null) { + $key = md5((string) $value); + } + + if (isset($existingMap[$key])) { + $key = $fallbackPrefix . $index++; + } + + return $key; + } + + /** + * Escapes value for JSCOMPS parameter + */ + public static function escapeJscompsValue($value) + { + return str_replace(array('\\', ',', ';'), array('\\\\', '\\,', '\\;'), $value); + } + + /** + * Unescapes value from JSCOMPS parameter + */ + public static function unescapeJscompsValue($value) + { + return str_replace(array('\\,', '\\;', '\\\\'), array(',', ';', '\\'), $value); } /** - * Collect vCard properties of a certain type + * Builds JSCOMPS parameter value and property parts array * - * @return array containing vCard properties that are truly set. + * @param array $components Array of NameComponent or AddressComponent objects + * @param array $kindToPosition Map of component kinds to positions + * @param string $defaultSep Default separator string + * @param int $partsCount Number of property parts (8 for N, 17 for ADR) + * @return array [$parts, $jscompsValue] */ - public static function collectVCardProps($vCardPropStr, $vCardChildren, $vCard) + public static function buildJscompsData($components, $kindToPosition, $defaultSep, $partsCount) { - $res = null; + $parts = array_fill(0, $partsCount, ''); + $jscompsEntries = array(); + $positionMap = array(); + + foreach ($components as $component) { + $kind = $component->getKind(); + $value = $component->getValue(); + + if ($kind === 'separator') { + $escaped = self::escapeJscompsValue($value); + $jscompsEntries[] = 's,' . $escaped; + } elseif (isset($kindToPosition[$kind])) { + $position = $kindToPosition[$kind]; + + if (!isset($positionMap[$position])) { + $positionMap[$position] = array(); + } - if (in_array($vCardPropStr, $vCardChildren)) { - $vCardPropsFound = $vCard[$vCardPropStr]; + $subIndex = count($positionMap[$position]); + $positionMap[$position][] = $value; - foreach ($vCardPropsFound as $vCardProp) { - if (isset($vCardProp)) { - array_push($res, $vCardProp); + $jscompsEntries[] = $subIndex === 0 ? (string)$position : $position . ',' . $subIndex; + + if ($subIndex === 0) { + $parts[$position] = $value; + } else { + $parts[$position] .= ',' . $value; } } } - return $res; + $defaultSepEntry = ''; + if ($defaultSep !== null && $defaultSep !== '') { + $defaultSepEntry = 's,' . self::escapeJscompsValue($defaultSep); + } + + $jscompsValue = $defaultSepEntry . ';' . implode(';', $jscompsEntries); + + return array($parts, $jscompsValue); } /** - * Convert from vCardType to JSContact context + * Parses JSCOMPS parameter value back into component objects * - * @return array converted contexts + * @param string $jscompsValue JSCOMPS parameter value + * @param array $parts Property parts array + * @param array $positionToKind Map of positions to component kinds + * @param $componentClass Class name for components (NameComponent or AddressComponent) + * @return array Array of component objects */ - public static function convertFromVcardType($vCardProp) + public static function parseJscompsData($jscompsValue, $parts, $positionToKind, $componentClass) { - if (isset($vCardProp['TYPE']) && !empty($vCardProp['TYPE'])) { - $jsContactContexts = []; - foreach ($vCardProp['TYPE'] as $paramValue) { - switch ($paramValue) { - case 'home': - $jsContactContexts['private'] = true; - break; + $entries = explode(';', $jscompsValue); + $components = array(); + + array_shift($entries); - case 'work': - $jsContactContexts['work'] = true; - break; + foreach ($entries as $entry) { + $entry = trim($entry); + if ($entry === '') { + continue; + } + + if (strpos($entry, 's,') === 0) { + $sepValue = self::unescapeJscompsValue(substr($entry, 2)); + $components[] = new $componentClass('separator', $sepValue); + } else { + $posParts = explode(',', $entry); + $position = (int)$posParts[0]; + $subIndex = isset($posParts[1]) ? (int)$posParts[1] : 0; - case 'other': - $jsContactContexts = null; - break; + if (!isset($positionToKind[$position]) || !isset($parts[$position])) { + continue; + } - default: - self::$logger = Logger::getInstance(); - self::$logger->warning("Unknown vCard TYPE parameter value encountered for vCard property " . - $vCardProp . " : " . $paramValue); - break; + $value = $parts[$position]; + + if (is_string($value) && strpos($value, ',') !== false) { + $values = explode(',', $value); + if (isset($values[$subIndex])) { + $value = $values[$subIndex]; + } } - return AdapterUtil::isSetNotNullAndNotEmpty($jsContactImppContexts) ? $jsContactImppContexts : null; + if ($value !== '' && $value !== null) { + $components[] = new $componentClass($positionToKind[$position], $value); + } } } + + return $components; + } + + /** + * Extracts default separator from JSCOMPS parameter value + */ + public static function getDefaultSeparatorFromJscomps($jscompsValue) + { + $entries = explode(';', $jscompsValue); + if (!empty($entries[0]) && strpos($entries[0], 's,') === 0) { + return self::unescapeJscompsValue(substr($entries[0], 2)); + } + return null; } /** - * Convert from JSContact context to vCard Type + * Builds component objects from property parts (non-JSCOMPS mode) * - * @return array vCard types - * TODO this does not work for multiple contexts - */ - public static function convertFromJscontactContexts($contexts) - { - $types = []; - - if (isset($contexts) && !empty($contexts)) { - foreach ($contexts as $context => $bool) { - switch ($context) { - case 'private': - array_push($types, 'home'); - break; - - case 'work': - array_push($types, 'work'); - break; - - default: - self::$logger = Logger::getInstance(); - self::$logger->error("Unknown value for the \"contexts\" property of a - OnlineService in JSContact.onlineServices property encountered during - conversion to IMPP vCard property. - Encountered value is: " . $onlineObjectContext); - break; - } + * @param array $parts Property parts array + * @param array $indexToKind Map of part indexes to component kinds + * @param $componentClass Class name for components + * @return array Array of component objects + */ + public static function buildComponentsFromParts($parts, $indexToKind, $componentClass) + { + + $components = array(); + + foreach ($indexToKind as $index => $kind) { + if (isset($parts[$index]) && $parts[$index] !== '') { + $components[] = new $componentClass($kind, $parts[$index]); } - } else { // In case $onlineObjectContexts is null, we set the vCard type to be 'other' - return ['other']; } - return $types; + + return $components; + } + + /** + * Standard N property kind to position mapping + */ + public static function getNameKindToPositionMap() + { + return array( + 'surname' => 0, + 'given' => 1, + 'given2' => 2, + 'title' => 3, + 'credential' => 4, + 'generation' => 6, + 'surname2' => 7, + ); + } + + /** + * Standard N property position to kind mapping + */ + public static function getNamePositionToKindMap() + { + return array( + 0 => 'surname', + 1 => 'given', + 2 => 'given2', + 3 => 'title', + 4 => 'credential', + 6 => 'generation', + 7 => 'surname2', + ); + } + + /** + * Standard ADR property kind to position mapping (RFC 9554) + */ + public static function getAddressKindToPositionMap() + { + return array( + 'postOfficeBox' => 0, + 'apartment' => 7, + 'room' => 8, + 'floor' => 9, + 'number' => 10, + 'name' => 2, + 'block' => 11, + 'building' => 12, + 'direction' => 13, + 'landmark' => 14, + 'district' => 15, + 'subdistrict' => 16, + 'locality' => 3, + 'region' => 4, + 'postcode' => 5, + 'country' => 6, + ); + } + + /** + * Standard ADR property position to kind mapping (RFC 9554) + */ + public static function getAddressPositionToKindMap() + { + return array( + 0 => 'postOfficeBox', + 2 => 'name', + 3 => 'locality', + 4 => 'region', + 5 => 'postcode', + 6 => 'country', + 7 => 'apartment', + 8 => 'room', + 9 => 'floor', + 10 => 'number', + 11 => 'block', + 12 => 'building', + 13 => 'direction', + 14 => 'landmark', + 15 => 'district', + 16 => 'subdistrict', + ); + } + + /** + * Basic ADR property position to kind mapping + */ + public static function getBasicAddressPositionToKindMap() + { + return array( + 0 => 'postOfficeBox', + 1 => 'apartment', + 2 => 'name', + 3 => 'locality', + 4 => 'region', + 5 => 'postcode', + 6 => 'country', + ); + } + + /** + * Maps vCard GENDER to JSContact grammatical gender + */ + public static function mapGenderToGrammatical($genderValue) + { + $mapping = array( + 'm' => 'male', + 'male' => 'male', + 'f' => 'female', + 'female' => 'female', + 'n' => 'neuter', + 'neuter' => 'neuter', + 'o' => 'animate', + 'other' => 'animate', + ); + + $key = strtolower(trim($genderValue)); + return isset($mapping[$key]) ? $mapping[$key] : null; + } + + /** + * Maps JSContact level to vCard LEVEL parameter + */ + public static function mapLevelToVcard($level) + { + $mapping = array( + 'low' => 'beginner', + 'medium' => 'average', + 'high' => 'expert', + ); + + if (!is_string($level)) { + return null; + } + + $key = strtolower(trim($level)); + return isset($mapping[$key]) ? $mapping[$key] : null; + } + + /** + * Maps vCard LEVEL parameter to JSContact level + */ + public static function mapLevelFromVcard($level) + { + $mapping = array( + 'beginner' => 'low', + 'average' => 'medium', + 'medium' => 'medium', + 'expert' => 'high', + ); + + $key = strtolower(trim($level)); + return isset($mapping[$key]) ? $mapping[$key] : null; + } + + /** + * Checks if value looks like a URI + */ + public static function isUri($value) + { + if (!is_string($value) || trim($value) === '') { + return false; + } + + $value = trim($value); + + return (bool) preg_match('/^[a-z][a-z0-9+.\-]*:/i', $value) + || (bool) preg_match('/^https?:\/\//i', $value); + } + + /** + * Normalizes geo: URI + */ + public static function normalizeGeoUri($coordinates) + { + if (!is_string($coordinates) || trim($coordinates) === '') { + return null; + } + + $coordinates = trim($coordinates); + + if (stripos($coordinates, 'geo:') === 0) { + return $coordinates; + } + + return 'geo:' . ltrim($coordinates, ':'); + } + + /** + * Converts place string (text or geo: URI) to Address object + * + * @param string $raw Raw place value + * @param bool $textAsFullAddress If true, plain text becomes fullAddress + * @return Address|null + */ + public static function placeToAddress($raw, $textAsFullAddress = true) + { + if (!is_string($raw)) { + return null; + } + + $raw = trim($raw); + if ($raw === '') { + return null; + } + + if (!class_exists('OpenXPort\\Jmap\\JSContact\\Address')) { + return null; + } + + $addr = new Address(); + + if (stripos($raw, 'geo:') === 0) { + $addr->setCoordinates($raw); + } elseif ($textAsFullAddress) { + $addr->setFullAddress($raw); + } + + return $addr; + } + + /** + * Extracts place value from Address object (coordinates or text) + * + * @param Address $addr + * @param bool $allowText If true, returns fullAddress as fallback + * @return string|null + */ + public static function addressToPlace(Address $addr, $allowText = true) + { + $coordinates = $addr->getCoordinates(); + if (is_string($coordinates) && trim($coordinates) !== '') { + return self::normalizeGeoUri($coordinates); + } + + if (!$allowText) { + return null; + } + + $text = $addr->getFullAddress(); + if (is_string($text) && trim($text) !== '') { + return trim($text); + } + + return null; + } + + /** + * Determines vCard property type for online service (IMPP, SOCIALPROFILE, or URL) + */ + public static function determineOnlinePropertyType($uri, $service) + { + if ($uri !== null && $uri !== '') { + $scheme = strtolower((string) parse_url($uri, PHP_URL_SCHEME)); + $imppSchemes = array( + 'xmpp', 'sip', 'sips', 'tel', 'aim', 'msnim', 'ymsgr', 'skype', 'irc' + ); + if (in_array($scheme, $imppSchemes, true)) { + return 'IMPP'; + } + } + + if ($service !== null && $service !== '') { + $socialServices = array( + 'facebook', 'twitter', 'x', 'linkedin', 'instagram', 'mastodon', + 'github', 'gitlab', 'reddit', 'youtube', 'tiktok', 'snapchat', + 'pinterest', 'flickr', 'vimeo', 'twitch', 'discord', 'telegram', + 'whatsapp', 'signal', 'matrix', 'bluesky', 'threads' + ); + if (in_array(strtolower((string) $service), $socialServices, true)) { + return 'SOCIALPROFILE'; + } + } + + return 'URL'; + } + + /** + * Assigns online service value to uri or user based on service type + */ + public static function assignOnlineValue($os, $propName, $value, $serviceType = null) + { + $serviceType = $serviceType !== null ? strtolower(trim((string) $serviceType)) : null; + + // IMPP is always URI + if ($propName === 'IMPP') { + $os->setUri($value); + return; + } + + // URI-style services + if (in_array($serviceType, ['aim', 'jabber', 'xmpp', 'sip', 'sips'], true)) { + $os->setUri($value); + return; + } + + // Username-style services + if (in_array($serviceType, ['skype', 'icq', 'msn', 'yahoo'], true)) { + if (self::isUri($value)) { + $os->setUri($value); + } else { + $os->setUser($value); + } + return; + } + + // Default: check if URI + if (self::isUri($value)) { + $os->setUri($value); + } else { + $os->setUser($value); + } + } + + /** + * Gets export value from online service + */ + public static function getOnlineServiceExportValue($os) + { + $uri = $os->getUri(); + $user = $os->getUser(); + $service = strtolower(trim((string) $os->getService())); + + if (in_array($service, ['aim', 'jabber', 'xmpp', 'sip'], true)) { + $result = $uri ?? $user; + } elseif (in_array($service, ['skype', 'icq', 'msn', 'yahoo'], true)) { + $result = $user ?? $uri; + } else { + $result = $uri ?? $user; + } + + if ($result === null) { + Logger::getInstance()->warning( + "OnlineService has neither uri nor user set for service: {$service}" + ); + } + + return $result; + } + + /** + * Creates Media object + */ + public static function createMediaObject($uri, $kind, $prop = null) + { + $media = new Media($kind, $uri); + + if ($prop !== null && isset($prop['MEDIATYPE'])) { + $media->setMediaType((string) $prop['MEDIATYPE']); + } + + self::applyCommonContextAndPref($media, $prop); + + return $media; + } + + /** + * Checks if value is non-empty string + */ + public static function isNonEmptyString($value) + { + return is_string($value) && trim($value) !== ''; + } + + /** + * Builds common vCard parameters for objects that have mediaType, contexts, pref. + * Used by media, directories, links, and crypto keys. + * + * @param object $obj The object to extract parameters from + * @param mixed $id The map key/id for PROP-ID parameter + * @return array The vCard parameters + */ + protected function buildCommonUriObjectParams($obj, $id = null) + { + $params = array(); + + // MediaType + if (method_exists($obj, 'getMediaType')) { + $mt = $obj->getMediaType(); + if (is_string($mt) && $mt !== '') { + $params['MEDIATYPE'] = $mt; + } + } + + // Contexts (TYPE parameter) + $types = $this->contextsToVcardTypeParam($obj); + if (!empty($types)) { + $params['TYPE'] = $types; + } + + // Preference + $pref = $this->prefToVcardParam($obj); + if ($pref !== null) { + $params['PREF'] = $pref; + } + + // PROP-ID + if ($id !== null) { + $params = $this->addPropIdParam($params, $id); + } + + return $params; } } diff --git a/tests/resources/JSCalendarComprehensive.json b/tests/resources/JSCalendarComprehensive.json new file mode 100644 index 0000000..b2f0a8e --- /dev/null +++ b/tests/resources/JSCalendarComprehensive.json @@ -0,0 +1,155 @@ +{ + "@type": "Event", + "uid": "comprehensive-test@example.com", + "title": "Comprehensive Test Event", + "description": "Event testing all newly implemented properties", + "start": "2026-05-15T14:00:00", + "timeZone": "Europe/Berlin", + "duration": "PT2H", + "showWithoutTime": true, + "status": "confirmed", + "color": "blue", + "priority": 5, + "privacy": "public", + "freeBusyStatus": "busy", + "coordinates": "geo:52.520008,13.404954", + "url": "https://example.com/events/test", + "method": "request", + "replyTo": { + "imip": "mailto:rsvp@example.com" + }, + "requestStatus": ["2.0;Success"], + "keywords": { + "meeting": true, + "important": true + }, + "locations": { + "1": { + "@type": "Location", + "name": "Conference Room A" + } + }, + "vLocations": { + "1": { + "@type": "Location", + "name": "Berlin Office", + "coordinates": "geo:52.520008,13.404954" + } + }, + "virtualLocations": { + "1": { + "@type": "VirtualLocation", + "name": "Zoom Meeting", + "uri": "https://zoom.us/j/123456789", + "features": { + "audio": true, + "video": true, + "chat": true + } + } + }, + "relatedTo": { + "parent-event-uid@example.com": { + "@type": "Relation", + "relation": { + "parent": true + } + } + }, + "participants": { + "participant1": { + "@type": "Participant", + "name": "Alice Attendee", + "sendTo": { + "imip": "mailto:alice@example.com" + }, + "roles": { + "attendee": true + }, + "participationStatus": "accepted", + "expectReply": true + }, + "participant2": { + "@type": "Participant", + "name": "Project Team", + "sendTo": { + "imip": "mailto:team@example.com" + }, + "kind": "group", + "roles": { + "attendee": true + }, + "memberOf": [ + "mailto:alice@example.com", + "mailto:bob@example.com" + ] + }, + "participant3": { + "@type": "Participant", + "name": "External Contact", + "sendTo": { + "imip": "mailto:external@partner.com" + }, + "roles": { + "attendee": true + }, + "links": { + "dir1": { + "@type": "Link", + "href": "https://contacts.example.com/external", + "rel": "describedby" + } + } + }, + "organizer": { + "@type": "Participant", + "name": "John Organizer", + "sendTo": { + "imip": "mailto:john@example.com" + }, + "roles": { + "owner": true + }, + "expectReply": false + } + }, + "alerts": { + "1": { + "@type": "Alert", + "trigger": { + "@type": "OffsetTrigger", + "offset": "-PT15M", + "relativeTo": "start" + }, + "action": "display" + } + }, + "links": { + "attachment1": { + "@type": "Link", + "href": "https://example.com/agenda.pdf", + "rel": "enclosure", + "contentType": "application/pdf", + "title": "Meeting Agenda", + "size": 52480 + } + }, + "recurrenceRules": [ + { + "@type": "RecurrenceRule", + "frequency": "weekly", + "interval": 2, + "byDay": [ + { + "@type": "NDay", + "day": "tu" + }, + { + "@type": "NDay", + "day": "th" + } + ], + "count": 10 + } + ] +} \ No newline at end of file diff --git a/tests/resources/comprehensive_test.ics b/tests/resources/comprehensive_test.ics new file mode 100644 index 0000000..a094207 --- /dev/null +++ b/tests/resources/comprehensive_test.ics @@ -0,0 +1,42 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp//CalDAV Client//EN +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VEVENT +UID:comprehensive-test@example.com +DTSTAMP:20260415T120000Z +DTSTART;VALUE=DATE:20260515 +DTEND;VALUE=DATE:20260515 +SUMMARY:Comprehensive Test Event +DESCRIPTION:Event testing all newly implemented properties +STATUS:CONFIRMED +CLASS:PUBLIC +TRANSP:OPAQUE +PRIORITY:5 +COLOR:blue +CATEGORIES:meeting,important +LOCATION:Conference Room A +GEO:52.520008;13.404954 +URL:https://example.com/events/test +RELATED-TO:parent-event-uid@example.com +REPLY-URL:mailto:rsvp@example.com +REQUEST-STATUS:2.0\;Success +RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH;COUNT=10 +CONFERENCE;LABEL=Zoom Meeting;FEATURE=AUDIO,VIDEO,CHAT:https://zoom.us/j/123456789 +ATTENDEE;CN=Alice Attendee;PARTSTAT=ACCEPTED;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:alice@example.com +ATTENDEE;CN=Project Team;CUTYPE=GROUP;MEMBER="mailto:alice@example.com","mailto:bob@example.com";ROLE=REQ-PARTICIPANT:mailto:team@example.com +ATTENDEE;CN=External Contact;DIR="https://contacts.example.com/external";ROLE=REQ-PARTICIPANT:mailto:external@partner.com +ORGANIZER;CN=John Organizer:mailto:john@example.com +ATTACH;FMTTYPE=application/pdf;LABEL=Meeting Agenda;SIZE=52480:https://example.com/agenda.pdf +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER;RELATED=START:-PT15M +END:VALARM +BEGIN:VLOCATION +UID:vloc-berlin-office +NAME:Berlin Office +GEO:52.520008;13.404954 +END:VLOCATION +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/tests/resources/icalendar_with_attach_label_and_size.ics b/tests/resources/icalendar_with_attach_label_and_size.ics new file mode 100644 index 0000000..b66b184 --- /dev/null +++ b/tests/resources/icalendar_with_attach_label_and_size.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:test-attach-label-size@example.com +DTSTAMP:20230520T090000Z +DTSTART:20230520T100000Z +DTEND:20230520T110000Z +SUMMARY:Attachment test +ATTACH;FMTTYPE=application/pdf;LABEL=Agenda;SIZE=12345:https://example.com/agenda.pdf +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/tests/resources/icalendar_with_audio_alarm.ics b/tests/resources/icalendar_with_audio_alarm.ics new file mode 100644 index 0000000..ce62c7c --- /dev/null +++ b/tests/resources/icalendar_with_audio_alarm.ics @@ -0,0 +1,15 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:test-audio-alert@example.com +DTSTAMP:20230520T090000Z +DTSTART:20230520T100000Z +DTEND:20230520T110000Z +SUMMARY:Audio alert test +BEGIN:VALARM +ACTION:AUDIO +TRIGGER:-PT5M +END:VALARM +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/tests/resources/jscalendar_with_attachment_in_recurrence_override.json b/tests/resources/jscalendar_with_attachment_in_recurrence_override.json new file mode 100644 index 0000000..af24e7f --- /dev/null +++ b/tests/resources/jscalendar_with_attachment_in_recurrence_override.json @@ -0,0 +1,29 @@ +{ + "@type": "Event", + "uid": "test-override-attach@example.com", + "title": "Recurring attachment test", + "start": "2023-05-20T10:00:00", + "duration": "PT1H", + "timeZone": "Etc/UTC", + "recurrenceRules": [ + { + "@type": "RecurrenceRule", + "frequency": "daily" + } + ], + "recurrenceOverrides": { + "2023-05-22T10:00:00": { + "title": "Override with attachment", + "links": { + "1": { + "@type": "Link", + "rel": "enclosure", + "href": "https://example.com/override.pdf", + "contentType": "application/pdf", + "title": "Override Agenda", + "size": 12345 + } + } + } + } +} \ No newline at end of file diff --git a/tests/resources/jscalendar_with_attachment_label_and_size.json b/tests/resources/jscalendar_with_attachment_label_and_size.json new file mode 100644 index 0000000..5cfa06e --- /dev/null +++ b/tests/resources/jscalendar_with_attachment_label_and_size.json @@ -0,0 +1,18 @@ +{ + "@type": "Event", + "uid": "test-attach-label-size@example.com", + "title": "Attachment test", + "start": "2023-05-20T10:00:00", + "duration": "PT1H", + "timeZone": "Etc/UTC", + "links": { + "1": { + "@type": "Link", + "href": "https://example.com/agenda.pdf", + "rel": "enclosure", + "contentType": "application/pdf", + "title": "Agenda", + "size": 12345 + } + } +} \ No newline at end of file diff --git a/tests/resources/jscontact_advanced.json b/tests/resources/jscontact_advanced.json deleted file mode 100644 index e6353eb..0000000 --- a/tests/resources/jscontact_advanced.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "@type" : "Card", - "name": { - "@type": "Name", - "components":[ - { "@type": "NameComponent", "type": "prefix", "value": "Mr." }, - { "@type": "NameComponent", "type": "given", "value": "John" }, - { "@type": "NameComponent", "type": "surname", "value": "Public" }, - { "@type": "NameComponent", "type": "middle", "value": "Quinlan" }, - { "@type": "NameComponent", "type": "suffix", "value": "Esq." } - ], - "sortAs": { - "surname": "Public", - "given": "John" - } - }, - "onlineServices": { - "x1": { - "@type": "OnlineService", - "user": "xmpp:alice@example.com", - "type": "impp", - "pref": 1 - }, - "e875a66d43010b9703a7300cc577b941" : { - "@type" : "OnlineService", - "type" : "username", - "service": "Skype", - "user" : "PupkinV" - } - }, - "organizations" : { - "9d5ed678fe57bcca610140957afab571" : { - "@type" : "Organization", - "name" : "Bubba Gump Shrimp Co.", - "units":[ - "Cleaning department" - ] - } - }, - "anniversaries":{ - "4abcc62e-7e23-4e47-88e4-811f1dd65117":{ - "@type":"Anniversary", - "date":"2023-02-28" - } - }, - "uid" : 1, - "updated" : "2008-04-24T19:52:43Z" -} diff --git a/tests/resources/jscontact_basic.json b/tests/resources/jscontact_basic.json deleted file mode 100644 index b2ca094..0000000 --- a/tests/resources/jscontact_basic.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "@type" : "Card", - "addresses" : { - "3bab34e047e67e16a9b491adc3982060" : { - "@type" : "Address", - "contexts" : { - "private" : true - }, - "country" : "United States of America", - "locality" : "Baytown", - "postcode" : "30314", - "region" : "LA", - "street" : [ - { - "@type" : "StreetComponent", - "type" : "name", - "value" : "42 Plantation St." - } - ] - }, - "dabe102fbe3f4a582a71a20f4fcb7336" : { - "@type" : "Address", - "contexts" : { - "work" : true - }, - "country" : "United States of America", - "locality" : "Baytown", - "postcode" : "30314", - "region" : "LA", - "street" : [ - { - "@type" : "StreetComponent", - "type" : "name", - "value" : "100 Waters Edge" - } - ] - } - }, - "anniversaries" : { - "1ee491b390a8abd10e990facd3e2f1e7" : { - "@type" : "Anniversary", - "date" : "2005-10-10", - "label" : "anniversary" - }, - "6aa5ba56bc3854d04dde19e3d7b600a2" : { - "@type" : "Anniversary", - "date" : "1995-05-05", - "type" : "birth" - } - }, - "categories" : { - "aCategory" : true, - "anotherCategory" : true - }, - "emails" : { - "404619330242b05e13fb2c1d55b344e7" : { - "@type" : "EmailAddress", - "contexts" : { - "work" : true - }, - "email" : "forrestgump-work@example.com" - }, - "a7683405b87b2df799183f8663bd981f" : { - "@type" : "EmailAddress", - "contexts" : { - "private" : true - }, - "email" : "forrestgump@example.com" - } - }, - "fullName" : "Forrest Gump", - "id" : 1, - "name" : { - "@type" : "Name", - "components" : [ - { - "@type" : "NameComponent", - "type" : "prefix", - "value" : "Mr." - }, - { - "@type" : "NameComponent", - "type" : "given", - "value" : "Forrest" - }, - { - "@type" : "NameComponent", - "type" : "surname", - "value" : "Gump" - }, - { - "@type" : "NameComponent", - "type" : "additional", - "value" : "MiddleName" - }, - { - "@type" : "NameComponent", - "type" : "suffix", - "value" : "Suffix" - } - ] - }, - "nickNames" : [ - "forresty" - ], - "notes" : "START OF NOTES\n\nThis is a comma, and a semicolon;DONE\n\nEND OF NOTES", - "online" : { - "46ec8a44a18c88d3cc298af974a4e56e" : { - "@type" : "Resource", - "label" : "url", - "resource" : "http://forrestgump.org/", - "type" : "uri" - }, - "b30aab96a38907207f24a122244b2484" : { - "@type" : "Resource", - "label" : "XMPP", - "resource" : "xmpp:forrestgump@example.org", - "type" : "username" - } - }, - "organizations" : { - "9d5ed678fe57bcca610140957afab571" : { - "@type" : "Organization", - "name" : "Bubba Gump Shrimp Co." - } - }, - "phones" : { - "551c1cab59b316ece08a6e32add88eb8" : { - "@type" : "Phone", - "contexts" : { - "work" : true - }, - "phone" : "(111) 555-1212" - }, - "639f9f67140a450ea878654c6f5b8128" : { - "@type" : "Phone", - "contexts" : { - "private" : true - }, - "phone" : "(404) 555-1212" - }, - "b1c7d9acea019e8b53cbbff8612041a4" : { - "@type" : "Phone", - "contexts" : { - "private" : true - }, - "features" : { - "pager" : true - }, - "label" : "blabla,blabla2", - "phone" : "(123) 555-1357" - } - }, - "photos" : { - "ca80fd849d1ceafd352ccaecf726a311" : { - "@type" : "File", - "href" : "https//homepagescaewiscedu/ece533/images/airplanepng" - } - }, - "speakToAs" : { - "@type" : "SpeakToAs", - "grammaticalGender" : "male" - }, - "titles" : { - "d41f7668a9479978409ed1da975844a1" : { - "@type" : "Title", - "title" : "Shrimp Man" - } - }, - "uid" : 1, - "updated" : "2008-04-24T19:52:43Z" -} diff --git a/tests/resources/jscontact_two_cards.json b/tests/resources/jscontact_two_cards.json deleted file mode 100644 index a22a3ad..0000000 --- a/tests/resources/jscontact_two_cards.json +++ /dev/null @@ -1,12 +0,0 @@ -[{ - "@type" : "Card", - "fullName" : "Forrest Gump", - "uid" : 1, - "updated" : "2008-04-24T19:52:43Z" -}, -{ - "@type" : "Card", - "fullName" : "Kamala Harris", - "uid" : 2, - "updated" : "2008-04-24T19:53:43Z" -}] diff --git a/tests/resources/jscontactcard_advanced.json b/tests/resources/jscontactcard_advanced.json new file mode 100644 index 0000000..b303a16 --- /dev/null +++ b/tests/resources/jscontactcard_advanced.json @@ -0,0 +1,42 @@ +{ + "@type": "Card", + "name": { + "@type": "Name", + "components": [ + { "@type": "NameComponent", "type": "prefix", "value": "Mr." }, + { "@type": "NameComponent", "type": "given", "value": "John" }, + { "@type": "NameComponent", "type": "surname", "value": "Public" }, + { "@type": "NameComponent", "type": "middle", "value": "Quinlan" }, + { "@type": "NameComponent", "type": "suffix", "value": "Esq." } + ] + }, + "onlineServices": { + "x1": { + "@type": "OnlineService", + "user": "xmpp:alice@example.com", + "type": "impp", + "pref": 1 + }, + "e875a66d43010b9703a7300cc577b941": { + "@type": "OnlineService", + "type": "username", + "service": "Skype", + "user": "PupkinV" + } + }, + "organizations": { + "9d5ed678fe57bcca610140957afab571": { + "@type": "Organization", + "name": "Bubba Gump Shrimp Co.", + "units": ["Cleaning department"] + } + }, + "anniversaries": { + "4abcc62e-7e23-4e47-88e4-811f1dd65117": { + "@type": "Anniversary", + "date": "2023-02-28" + } + }, + "uid": "1", + "updated": "2008-04-24T19:52:43Z" +} \ No newline at end of file diff --git a/tests/resources/jscontactcard_basic.json b/tests/resources/jscontactcard_basic.json new file mode 100644 index 0000000..5276c58 --- /dev/null +++ b/tests/resources/jscontactcard_basic.json @@ -0,0 +1,142 @@ +{ + "@type" : "Card", + "uid" : "forrest-gump-1", + "updated" : "2008-04-24T19:52:43Z", + + "name" : { + "@type" : "Name", + "full" : "Forrest Gump", + "components" : [ + { "@type" : "NameComponent", "kind" : "title", "value" : "Mr." }, + { "@type" : "NameComponent", "kind" : "given", "value" : "Forrest" }, + { "@type" : "NameComponent", "kind" : "given2", "value" : "MiddleName" }, + { "@type" : "NameComponent", "kind" : "surname", "value" : "Gump" }, + { "@type" : "NameComponent", "kind" : "credential", "value" : "Suffix" } + ] + }, + + "nicknames" : { + "nick1" : { "@type" : "Nickname", "name" : "forresty" } + }, + + "organizations" : { + "9d5ed678fe57bcca610140957afab571" : { + "@type" : "Organization", + "name" : "Bubba Gump Shrimp Co." + } + }, + + "titles" : { + "d41f7668a9479978409ed1da975844a1" : { + "@type" : "Title", + "name" : "Shrimp Man", + "kind" : "title" + } + }, + + "emails" : { + "a7683405b87b2df799183f8663bd981f" : { + "@type" : "EmailAddress", + "address" : "forrestgump@example.com", + "contexts" : { "private" : true } + }, + "404619330242b05e13fb2c1d55b344e7" : { + "@type" : "EmailAddress", + "address" : "forrestgump-work@example.com", + "contexts" : { "work" : true } + } + }, + + "phones" : { + "551c1cab59b316ece08a6e32add88eb8" : { + "@type" : "Phone", + "number" : "(111) 555-1212", + "contexts" : { "work" : true } + }, + "639f9f67140a450ea878654c6f5b8128" : { + "@type" : "Phone", + "number" : "(404) 555-1212", + "contexts" : { "private" : true } + }, + "b1c7d9acea019e8b53cbbff8612041a4" : { + "@type" : "Phone", + "number" : "(123) 555-1357", + "contexts" : { "private" : true }, + "features" : { "pager" : true }, + "label" : "blabla,blabla2" + } + }, + + "addresses" : { + "dabe102fbe3f4a582a71a20f4fcb7336" : { + "@type" : "Address", + "contexts" : { "work" : true }, + "components" : [ + { "@type" : "AddressComponent", "kind" : "name", "value" : "100 Waters Edge" }, + { "@type" : "AddressComponent", "kind" : "locality", "value" : "Baytown" }, + { "@type" : "AddressComponent", "kind" : "region", "value" : "LA" }, + { "@type" : "AddressComponent", "kind" : "postcode", "value" : "30314" }, + { "@type" : "AddressComponent", "kind" : "country", "value" : "United States of America" } + ] + }, + "3bab34e047e67e16a9b491adc3982060" : { + "@type" : "Address", + "contexts" : { "private" : true }, + "components" : [ + { "@type" : "AddressComponent", "kind" : "name", "value" : "42 Plantation St." }, + { "@type" : "AddressComponent", "kind" : "locality", "value" : "Baytown" }, + { "@type" : "AddressComponent", "kind" : "region", "value" : "LA" }, + { "@type" : "AddressComponent", "kind" : "postcode", "value" : "30314" }, + { "@type" : "AddressComponent", "kind" : "country", "value" : "United States of America" } + ] + } + }, + + "noteObjects" : { + "n1" : { + "@type" : "Note", + "note" : "START OF NOTES\n\nThis is a comma, and a semicolon;DONE\n\nEND OF NOTES" + } + }, + + "keywords" : { + "aCategory" : true, + "anotherCategory": true + }, + + "anniversaries" : { + "6aa5ba56bc3854d04dde19e3d7b600a2" : { + "@type" : "Anniversary", + "kind" : "birth", + "date" : "1995-05-05" + }, + "1ee491b390a8abd10e990facd3e2f1e7" : { + "@type" : "Anniversary", + "kind" : "wedding", + "date" : "2005-10-10", + "label" : "anniversary" + } + }, + + "onlineServices" : { + "os1" : { + "@type" : "OnlineService", + "user" : "xmpp:forrestgump@example.org" + } + }, + + "links" : { + "l1" : { + "@type" : "Link", + "uri" : "http://forrestgump.org/" + } + }, + + "media" : { + "m1" : { + "@type" : "Media", + "kind" : "photo", + "uri" : "https://homepages.cae.wisc.edu/~ece533/images/airplane.png" + } + } +} \ No newline at end of file diff --git a/tests/resources/jscontactcard_nc.json b/tests/resources/jscontactcard_nc.json new file mode 100644 index 0000000..3142292 --- /dev/null +++ b/tests/resources/jscontactcard_nc.json @@ -0,0 +1,24 @@ +{ + "@type": "Card", + "uid": "urn:uuid:12345678-1234-1234-1234-123456789abc", + "name": { + "full": "Jane Smith" + }, + "onlineServices": { + "os1": { + "service": "twitter", + "user": "janesmith", + "label": "X-SOCIALPROFILE" + }, + "os2": { + "service": "github", + "uri": "https://github.com/janesmith", + "label": "X-SOCIALPROFILE" + }, + "os3": { + "service": "linkedin", + "user": "jane-smith-123", + "label": "X-SOCIALPROFILE" + } + } +} \ No newline at end of file diff --git a/tests/resources/jscontactcard_two_cards.json b/tests/resources/jscontactcard_two_cards.json new file mode 100644 index 0000000..3d017a2 --- /dev/null +++ b/tests/resources/jscontactcard_two_cards.json @@ -0,0 +1,14 @@ +[ + { + "@type": "Card", + "uid": "forrest-gump-uid-1", + "updated": "2008-04-24T19:52:43Z", + "name": { "full": "Forrest Gump" } + }, + { + "@type": "Card", + "uid": "kamala-harris-uid-2", + "updated": "2008-04-24T19:53:43Z", + "name": { "full": "Kamala Harris" } + } +] \ No newline at end of file diff --git a/tests/resources/nextcloud_socialprofile.vcf b/tests/resources/nextcloud_socialprofile.vcf new file mode 100644 index 0000000..4c51ad7 --- /dev/null +++ b/tests/resources/nextcloud_socialprofile.vcf @@ -0,0 +1,10 @@ +BEGIN:VCARD +VERSION:3.0 +FN:John Doe +N:Doe;John;;; +EMAIL;TYPE=INTERNET;TYPE=HOME:john@example.com +X-SOCIALPROFILE;TYPE=twitter:johndoe +X-SOCIALPROFILE;TYPE=facebook:john.doe.123 +X-SOCIALPROFILE;TYPE=linkedin:johndoe +UID:12345678-1234-1234-1234-123456789abc +END:VCARD \ No newline at end of file diff --git a/tests/resources/rc_vcard_advanced.vcf b/tests/resources/rc_vcard_advanced.vcf new file mode 100644 index 0000000..360dbc7 --- /dev/null +++ b/tests/resources/rc_vcard_advanced.vcf @@ -0,0 +1,29 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Roundcube Webmail//NONSGML Roundcube Contact//EN +UID:rc-complex-001 +REV:20260316T120000Z +FN:Dr. Jörg Åström +N:Åström;Jörg;;Dr.; +NICKNAME:Jörgi +EMAIL;TYPE=INTERNET,HOME:joerg.aestroem@example.com +EMAIL;TYPE=INTERNET,WORK:joerg.astrom@work.example +TEL;TYPE=CELL,VOICE:+49-170-555-0101 +TEL;TYPE=HOME,VOICE:+49-30-555-0102 +TEL;TYPE=PAGER:123-pager +ADR;TYPE=HOME:;;Münzstraße 12;Berlin;;10178;Germany +ORG:Äcme GmbH;Forschung und Entwicklung +TITLE:Leitender Entwickler +NOTE:Roundcube test contact with UTF-8 characters: ä ö ü ß é Å. +URL:https://example.com/~joerg +BDAY:1988-04-12 +X-MAIDENNAME:Öster +X-GENDER:male +X-DEPARTMENT:Forschung +X-AIM:joergaim +X-JABBER:joerg@jabber.example +X-SKYPE-USERNAME:joerg.astrom.skype +X-MANAGER:Renée Manager +X-ASSISTANT:Björk Assistant +X-SPOUSE:Zoë Åström +END:VCARD \ No newline at end of file diff --git a/tests/resources/rc_vcard_basic.vcf b/tests/resources/rc_vcard_basic.vcf new file mode 100644 index 0000000..049e0da --- /dev/null +++ b/tests/resources/rc_vcard_basic.vcf @@ -0,0 +1,9 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Roundcube Webmail//NONSGML Roundcube Contact//EN +UID:c1 +FN:Jane Doe +N:Doe;Jane;;; +EMAIL;TYPE=INTERNET:jane.doe@example.com +TEL;TYPE=CELL:+49-170-555-0101 +END:VCARD diff --git a/tests/resources/vcard_altid_language.vcf b/tests/resources/vcard_altid_language.vcf new file mode 100644 index 0000000..785d21f --- /dev/null +++ b/tests/resources/vcard_altid_language.vcf @@ -0,0 +1,8 @@ +BEGIN:VCARD +VERSION:4.0 +UID:test-123 +TITLE;PROP-ID=t1;ALTID=1;LANGUAGE=en:Chief Executive Officer +TITLE;PROP-ID=t2;ALTID=1;LANGUAGE=de:Geschäftsführer +NOTE;PROP-ID=n1;LANGUAGE=en:Hello +NOTE;PROP-ID=n2;LANGUAGE=de:Hallo +END:VCARD \ No newline at end of file diff --git a/tests/resources/vcard_with_jscomps.vcf b/tests/resources/vcard_with_jscomps.vcf new file mode 100644 index 0000000..f316160 --- /dev/null +++ b/tests/resources/vcard_with_jscomps.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +FN:山田太郎 +N;JSCOMPS=";0;1":山田;太郎;;; +ADR;JSCOMPS="s,, ;10;s, ;2;3";TYPE=work:;;54321 Oak St;Reston;VA;20190;USA;;;;54321;Oak St;;;;;; +UID:test-001 +END:VCARD \ No newline at end of file diff --git a/tests/unit/JSCalendarICalendarAdapterTest.php b/tests/unit/JSCalendarICalendarAdapterTest.php index 36b69b1..e08182b 100644 --- a/tests/unit/JSCalendarICalendarAdapterTest.php +++ b/tests/unit/JSCalendarICalendarAdapterTest.php @@ -543,7 +543,7 @@ public function testGoogleEventRoundtrip(): void $this->assertEquals($this->jsCalendarBefore->getStart(), $this->jsCalendarAfter[0]->getStart()); - $this->assertEquals($this->jsCalendarBefore->getTimeZone(), $this->jsCalendarAfter[0]->getTimezone()); + $this->assertEquals($this->jsCalendarBefore->getTimeZone(), $this->jsCalendarAfter[0]->getTimeZone()); $this->assertEquals($this->jsCalendarBefore->getUid(), $this->jsCalendarAfter[0]->getUid()); @@ -596,14 +596,15 @@ public function testShowWithoutTimeOverride(): void $this->assertNull($this->jsCalendarAfter->getRecurrenceOverrides()["2023-05-03T00:00:00"]->getShowWithoutTime()); } - public function testMapICalendarAttachBinary() { + public function testMapICalendarAttachBinary() + { $this->mapICalendar('/../resources/icalendar_with_attach_binary.ics'); $this->assertCount(1, $this->jsCalendarAfter->getLinks()); $this->assertEquals("enclosure", $this->jsCalendarAfter->getLinks()["1"]->getRel()); $this->assertEquals("text/plain", $this->jsCalendarAfter->getLinks()["1"]->getContentType()); $this->assertEquals("test.txt", $this->jsCalendarAfter->getLinks()["1"]->getTitle()); - $this->assertEquals("data:text/plain;base64,U0ZMb2dObwlTRkxvYWR" . + $this->assertEquals("data:text/plain;base64,U0ZMb2dObwlTRkxvYWR" . "lZERhdGUNCjkxNzY3NC8xCTI3LzExLzIwMTIgMTg6MzANCjkxMjIwNS8xCTI3LzExLzIwMTIgM" . "Tg6MzANCjkxMjI0Ni8xCTI3LzExLzIwMTIgMTg6MzANCjkxMjI1Mi8xCTI3LzExLzIwMTIgMTg" . "6MzANCjkxMjQyMS8xCTI3LzExLzIwMTIgMTg6MzANCjkxMjQyMi8xCTI3LzExLzIwMTIgMTg6M" . @@ -622,7 +623,8 @@ public function testMapICalendarAttachBinary() { "zIwMTIgMTg6MzANCg==", $this->jsCalendarAfter->getLinks()["1"]->getHref()); } - public function testMapICalendarAttachUri() { + public function testMapICalendarAttachUri() + { $this->mapICalendar('/../resources/icalendar_with_attach_uri.ics'); $this->assertCount(1, $this->jsCalendarAfter->getLinks()); @@ -631,7 +633,8 @@ public function testMapICalendarAttachUri() { $this->assertEquals("/test/nextcloud26/index.php/f/1116", $this->jsCalendarAfter->getLinks()["1"]->getHref()); } - public function testAttachmentsBinaryRoundtrip() { + public function testAttachmentsBinaryRoundtrip() + { $this->mapJSCalendar(__DIR__ . '/../resources/jscalendar_with_attachment_binary.json'); $this->assertInstanceOf(CalendarEvent::class, $this->jsCalendarAfter); @@ -645,5 +648,443 @@ public function testAttachmentsBinaryRoundtrip() { $this->assertEquals($this->jsCalendarBefore->getLinks()["someid"]->getTitle(), $this->jsCalendarAfter->getLinks()["1"]->getTitle()); } - // TODO: Add roundtrip testing for Attachments in recurrence overrides. + /** + * Test whether an empty recurrence override is mapped to RDATE. + */ + public function testRDateFromEmptyRecurrenceOverride() + { + $this->jsCalendarBefore = new CalendarEvent(); + $this->jsCalendarBefore->setType("Event"); + $this->jsCalendarBefore->setUid("test-rdate@example.com"); + $this->jsCalendarBefore->setTitle("Test event"); + $this->jsCalendarBefore->setStart("2023-05-20T10:00:00"); + $this->jsCalendarBefore->setDuration("PT1H"); + $this->jsCalendarBefore->setTimeZone("Etc/UTC"); + + $recurrenceRule = new \OpenXPort\Jmap\Calendar\RecurrenceRule(); + $recurrenceRule->setType("RecurrenceRule"); + $recurrenceRule->setFrequency("daily"); + $this->jsCalendarBefore->setRecurrenceRules([$recurrenceRule]); + + $emptyOverride = new \OpenXPort\Jmap\Calendar\PatchObject(); + + $this->jsCalendarBefore->setRecurrenceOverrides([ + "2023-05-22T10:00:00" => $emptyOverride, + ]); + + $this->iCalendarData = $this->mapper->mapFromJmap( + array("c1" => $this->jsCalendarBefore), + $this->adapter + ); + + $this->iCalendar = Reader::read($this->iCalendarData[0]["c1"]["iCalendar"]); + + $this->assertStringContainsString( + "RDATE:20230522T100000Z", + $this->iCalendarData[0]["c1"]["iCalendar"] + ); + } + + /** + * Test whether ATTACH parameters LABEL and SIZE are mapped correctly from a file. + */ + public function testMapICalendarAttachLabelAndSizeFromFile() + { + $this->mapICalendar('/../resources/icalendar_with_attach_label_and_size.ics'); + + $this->assertCount(1, $this->jsCalendarAfter->getLinks()); + $this->assertEquals("enclosure", $this->jsCalendarAfter->getLinks()["1"]->getRel()); + $this->assertEquals("application/pdf", $this->jsCalendarAfter->getLinks()["1"]->getContentType()); + $this->assertEquals("Agenda", $this->jsCalendarAfter->getLinks()["1"]->getTitle()); + $this->assertEquals(12345, $this->jsCalendarAfter->getLinks()["1"]->getSize()); + $this->assertEquals("https://example.com/agenda.pdf", $this->jsCalendarAfter->getLinks()["1"]->getHref()); + } + + /** + * Test whether Link title and size are mapped to ATTACH LABEL and SIZE in roundtrip. + */ + public function testAttachmentsLabelAndSizeRoundtripFromFile() + { + $this->mapJSCalendar(__DIR__ . '/../resources/jscalendar_with_attachment_label_and_size.json'); + + $this->assertStringContainsString( + "ATTACH;FMTTYPE=application/pdf;LABEL=Agenda;SIZE=12345:https://example.com/agenda.pdf", + str_replace("\r\n ", "", $this->iCalendarData[0]["c1"]["iCalendar"]) + ); + + $this->assertCount(1, $this->jsCalendarAfter->getLinks()); + $this->assertEquals("Agenda", $this->jsCalendarAfter->getLinks()["1"]->getTitle()); + $this->assertEquals(12345, $this->jsCalendarAfter->getLinks()["1"]->getSize()); + $this->assertEquals("application/pdf", $this->jsCalendarAfter->getLinks()["1"]->getContentType()); + } + + /** + * Test whether attachments in recurrence overrides are mapped correctly. + */ + public function testAttachmentsInRecurrenceOverrideRoundtrip() + { + $this->mapJSCalendar(__DIR__ . '/../resources/jscalendar_with_attachment_in_recurrence_override.json'); + + $this->assertCount(2, $this->iCalendar->VEVENT); + + $this->assertEquals( + "RECURRENCE-ID:20230522T100000Z", + str_replace("\r\n", "", $this->iCalendar->VEVENT[1]->{"RECURRENCE-ID"}->serialize()) + ); + + $this->assertStringContainsString( + "ATTACH", + $this->iCalendar->VEVENT[1]->serialize() + ); + + $this->assertStringContainsString( + "ATTACH;FMTTYPE=application/pdf;LABEL=Override Agenda;SIZE=12345:https://example.com/override.pdf", + str_replace("\r\n ", "", $this->iCalendar->VEVENT[1]->serialize()) + ); + + $this->assertNotEmpty($this->jsCalendarAfter->getRecurrenceOverrides()["2023-05-22T10:00:00"]->getLinks()); + $this->assertEquals( + "https://example.com/override.pdf", + $this->jsCalendarAfter->getRecurrenceOverrides()["2023-05-22T10:00:00"]->getLinks()["1"]->getHref() + ); + $this->assertEquals( + "application/pdf", + $this->jsCalendarAfter->getRecurrenceOverrides()["2023-05-22T10:00:00"]->getLinks()["1"]->getContentType() + ); + $this->assertEquals( + "Override Agenda", + $this->jsCalendarAfter->getRecurrenceOverrides()["2023-05-22T10:00:00"]->getLinks()["1"]->getTitle() + ); + $this->assertEquals( + 12345, + $this->jsCalendarAfter->getRecurrenceOverrides()["2023-05-22T10:00:00"]->getLinks()["1"]->getSize() + ); + } + + /** + * Test whether ACTION:AUDIO is correctly mapped to display. + */ + public function testMapICalendarAudioAlertFromFile() + { + $this->mapICalendar('/../resources/icalendar_with_audio_alarm.ics'); + + $this->assertCount(1, $this->jsCalendarAfter->getAlerts()); + $this->assertNull($this->jsCalendarAfter->getAlerts()["1"]->getAction()); + $this->assertEquals("-PT5M", $this->jsCalendarAfter->getAlerts()["1"]->getTrigger()->getOffset()); + $this->assertEquals("OffsetTrigger", $this->jsCalendarAfter->getAlerts()["1"]->getTrigger()->getType()); + } + + public function testComprehensiveEventRoundtrip() + { + $this->mapJSCalendar(__DIR__ . '/../resources/JSCalendarComprehensive.json'); + + $icalString = $this->iCalendar->serialize(); + $icalStringUnwrapped = str_replace(["\r\n ", "\n "], '', $icalString); + + $this->assertEquals("geo:52.520008,13.404954", $this->jsCalendarBefore->getCoordinates()); + $this->assertStringContainsString("GEO:52.520008;13.404954", $icalString); + $this->assertEquals("geo:52.520008,13.404954", $this->jsCalendarAfter->getCoordinates()); + + $this->assertNotEmpty($this->jsCalendarBefore->getVLocations()); + $this->assertEquals("Berlin Office", $this->jsCalendarBefore->getVLocations()["1"]->getName()); + $this->assertEquals("geo:52.520008,13.404954", $this->jsCalendarBefore->getVLocations()["1"]->getCoordinates()); + $this->assertStringContainsString("BEGIN:VLOCATION", $icalString); + $this->assertStringContainsString("NAME:Berlin Office", $icalString); + $this->assertStringContainsString("GEO:", $icalString); + $this->assertNotEmpty($this->jsCalendarAfter->getVLocations()); + $this->assertEquals("Berlin Office", $this->jsCalendarAfter->getVLocations()["1"]->getName()); + $this->assertEquals("geo:52.520008,13.404954", $this->jsCalendarAfter->getVLocations()["1"]->getCoordinates()); + + $this->assertNotEmpty($this->jsCalendarBefore->getVirtualLocations()); + $this->assertEquals("Zoom Meeting", $this->jsCalendarBefore->getVirtualLocations()["1"]->getName()); + $this->assertEquals("https://zoom.us/j/123456789", $this->jsCalendarBefore->getVirtualLocations()["1"]->getUri()); + $this->assertStringContainsString("CONFERENCE;", $icalStringUnwrapped); + $this->assertStringContainsString("https://zoom.us/j/123456789", $icalStringUnwrapped); + $this->assertStringContainsString("LABEL=Zoom Meeting", $icalString); + $this->assertStringContainsString("FEATURE=", $icalString); + $this->assertNotEmpty($this->jsCalendarAfter->getVirtualLocations()); + $this->assertEquals("https://zoom.us/j/123456789", $this->jsCalendarAfter->getVirtualLocations()["1"]->getUri()); + $this->assertEquals("Zoom Meeting", $this->jsCalendarAfter->getVirtualLocations()["1"]->getName()); + + $this->assertEquals("https://example.com/events/test", $this->jsCalendarBefore->getUrl()); + $this->assertStringContainsString("URL;", $icalStringUnwrapped); + $this->assertStringContainsString("https://example.com/events/test", $icalStringUnwrapped); + $this->assertEquals("https://example.com/events/test", $this->jsCalendarAfter->getUrl()); + + $this->assertTrue($this->jsCalendarBefore->getShowWithoutTime()); + $this->assertTrue($this->jsCalendarAfter->getShowWithoutTime()); + if (!str_contains($icalString, 'VALUE=DATE')) { + $this->assertStringContainsString("SHOW-WITHOUT-TIME", $icalString); + } + + $this->assertNotEmpty($this->jsCalendarBefore->getRelatedTo()); + $this->assertArrayHasKey("parent-event-uid@example.com", $this->jsCalendarBefore->getRelatedTo()); + $this->assertStringContainsString("RELATED-TO:parent-event-uid@example.com", $icalString); + $this->assertNotEmpty($this->jsCalendarAfter->getRelatedTo()); + $this->assertArrayHasKey("parent-event-uid@example.com", $this->jsCalendarAfter->getRelatedTo()); + + $participantsBefore = $this->jsCalendarBefore->getParticipants(); + $this->assertNotEmpty($participantsBefore); + $hasDir = false; + foreach ($participantsBefore as $participant) { + $links = $participant->getLinks(); + if ($links && isset($links["dir1"])) { + $hasDir = true; + $this->assertEquals("https://contacts.example.com/external", $links["dir1"]->getHref()); + } + } + $this->assertTrue($hasDir); + $this->assertStringContainsString("DIR=", $icalString); + + $participantsAfter = $this->jsCalendarAfter->getParticipants(); + $hasDirAfter = false; + foreach ($participantsAfter as $participant) { + $links = $participant->getLinks(); + if ($links) { + foreach ($links as $link) { + if ($link->getRel() === "describedby") { + $hasDirAfter = true; + $this->assertEquals("https://contacts.example.com/external", $link->getHref()); + } + } + } + } + $this->assertTrue($hasDirAfter); + + $hasMember = false; + foreach ($participantsBefore as $participant) { + $memberOf = $participant->getMemberOf(); + if ($memberOf) { + $hasMember = true; + $this->assertContains("mailto:alice@example.com", $memberOf); + } + } + $this->assertTrue($hasMember); + $this->assertStringContainsString("MEMBER=", $icalString); + + $hasMemberAfter = false; + foreach ($participantsAfter as $participant) { + $memberOf = $participant->getMemberOf(); + if ($memberOf) { + $hasMemberAfter = true; + $this->assertIsArray($memberOf); + $this->assertNotEmpty($memberOf); + } + } + $this->assertTrue($hasMemberAfter); + + $this->assertEquals("request", $this->jsCalendarBefore->getMethod()); + $this->assertStringContainsString("METHOD:REQUEST", $icalString); + $this->assertEquals("request", $this->jsCalendarAfter->getMethod()); + + $this->assertNotEmpty($this->jsCalendarBefore->getReplyTo()); + $this->assertEquals("mailto:rsvp@example.com", $this->jsCalendarBefore->getReplyTo()["imip"]); + $this->assertStringContainsString("REPLY-URL:mailto:rsvp@example.com", $icalString); + $this->assertNotEmpty($this->jsCalendarAfter->getReplyTo()); + $this->assertEquals("mailto:rsvp@example.com", $this->jsCalendarAfter->getReplyTo()["imip"]); + + $this->assertNotEmpty($this->jsCalendarBefore->getRequestStatus()); + $this->assertContains("2.0;Success", $this->jsCalendarBefore->getRequestStatus()); + $this->assertStringContainsString("REQUEST-STATUS:2.0\\;Success", $icalString); + $this->assertNotEmpty($this->jsCalendarAfter->getRequestStatus()); + $this->assertContains("2.0;Success", $this->jsCalendarAfter->getRequestStatus()); + } + + public function testComprehensiveICSRoundtrip() + { + $this->mapICalendar('/../resources/comprehensive_test.ics'); + + $icsString = $this->iCalendar->serialize(); + $icsStringUnwrapped = str_replace(["\r\n ", "\n "], '', $icsString); + + // Test METHOD property (calendar-level) + $this->assertEquals("request", $this->jsCalendarAfter->getMethod()); + $this->assertStringContainsString("METHOD:REQUEST", $icsString); + + // Test basic properties + $this->assertEquals("comprehensive-test@example.com", $this->jsCalendarAfter->getUid()); + $this->assertStringContainsString("UID:comprehensive-test@example.com", $icsString); + + $this->assertEquals("Comprehensive Test Event", $this->jsCalendarAfter->getTitle()); + $this->assertStringContainsString("SUMMARY:Comprehensive Test Event", $icsString); + + $this->assertEquals("Event testing all newly implemented properties", $this->jsCalendarAfter->getDescription()); + $this->assertStringContainsString("DESCRIPTION:Event testing all newly implemented properties", $icsString); + + // Test status, priority, color + $this->assertEquals("confirmed", $this->jsCalendarAfter->getStatus()); + $this->assertStringContainsString("STATUS:CONFIRMED", $icsString); + + $this->assertEquals("public", $this->jsCalendarAfter->getPrivacy()); + $this->assertStringContainsString("CLASS:PUBLIC", $icsString); + + $this->assertEquals(5, $this->jsCalendarAfter->getPriority()); + $this->assertStringContainsString("PRIORITY:5", $icsString); + + $this->assertEquals("blue", $this->jsCalendarAfter->getColor()); + $this->assertStringContainsString("COLOR:blue", $icsString); + + // Test keywords + $keywords = $this->jsCalendarAfter->getKeywords(); + $this->assertNotEmpty($keywords); + $this->assertArrayHasKey("meeting", $keywords); + $this->assertArrayHasKey("important", $keywords); + $this->assertStringContainsString("CATEGORIES:meeting,important", $icsString); + + // Test location + $locations = $this->jsCalendarAfter->getLocations(); + $this->assertNotEmpty($locations); + $this->assertEquals("Conference Room A", $locations["1"]->getName()); + $this->assertStringContainsString("LOCATION:Conference Room A", $icsString); + + // Test GEO coordinates + $this->assertEquals("geo:52.520008,13.404954", $this->jsCalendarAfter->getCoordinates()); + $this->assertStringContainsString("GEO:52.520008;13.404954", $icsString); + + // Test URL property + $this->assertEquals("https://example.com/events/test", $this->jsCalendarAfter->getUrl()); + $this->assertStringContainsString("URL", $icsStringUnwrapped); + $this->assertStringContainsString("https://example.com/events/test", $icsStringUnwrapped); + + // Test RELATED-TO + $relatedTo = $this->jsCalendarAfter->getRelatedTo(); + $this->assertNotEmpty($relatedTo); + $this->assertArrayHasKey("parent-event-uid@example.com", $relatedTo); + $this->assertStringContainsString("RELATED-TO:parent-event-uid@example.com", $icsString); + + // Test REPLY-URL (replyTo) + $replyTo = $this->jsCalendarAfter->getReplyTo(); + $this->assertNotEmpty($replyTo); + $this->assertEquals("mailto:rsvp@example.com", $replyTo["imip"]); + $this->assertStringContainsString("REPLY-URL:mailto:rsvp@example.com", $icsString); + + // Test REQUEST-STATUS + $requestStatus = $this->jsCalendarAfter->getRequestStatus(); + $this->assertNotEmpty($requestStatus); + $this->assertContains("2.0;Success", $requestStatus); + $this->assertStringContainsString("REQUEST-STATUS:2.0\\;Success", $icsString); + + // Test recurrence rules + $recurrenceRules = $this->jsCalendarAfter->getRecurrenceRules(); + $this->assertNotEmpty($recurrenceRules); + $this->assertEquals("weekly", $recurrenceRules[0]->getFrequency()); + $this->assertEquals(2, $recurrenceRules[0]->getInterval()); + $this->assertEquals(10, $recurrenceRules[0]->getCount()); + $this->assertStringContainsString("RRULE:FREQ=WEEKLY;INTERVAL=2", $icsString); + $this->assertStringContainsString("BYDAY=TU,TH", $icsString); + $this->assertStringContainsString("COUNT=10", $icsString); + + // Test virtual locations (CONFERENCE) + $virtualLocations = $this->jsCalendarAfter->getVirtualLocations(); + $this->assertNotEmpty($virtualLocations); + $this->assertEquals("Zoom Meeting", $virtualLocations["1"]->getName()); + $this->assertEquals("https://zoom.us/j/123456789", $virtualLocations["1"]->getUri()); + $this->assertStringContainsString("CONFERENCE;", $icsStringUnwrapped); + $this->assertStringContainsString("https://zoom.us/j/123456789", $icsStringUnwrapped); + $this->assertStringContainsString("LABEL=Zoom Meeting", $icsString); + $this->assertStringContainsString("FEATURE=AUDIO,VIDEO,CHAT", $icsString); + + // Test VLOCATION + $vLocations = $this->jsCalendarAfter->getVLocations(); + $this->assertNotEmpty($vLocations); + $this->assertEquals("Berlin Office", $vLocations["1"]->getName()); + $this->assertEquals("geo:52.520008,13.404954", $vLocations["1"]->getCoordinates()); + $this->assertStringContainsString("BEGIN:VLOCATION", $icsString); + $this->assertStringContainsString("NAME:Berlin Office", $icsString); + $this->assertStringContainsString("END:VLOCATION", $icsString); + + // Test participants + $participants = $this->jsCalendarAfter->getParticipants(); + $this->assertNotEmpty($participants); + + // Test organizer + $hasOrganizer = false; + foreach ($participants as $participant) { + $roles = $participant->getRoles(); + if ($roles && isset($roles["owner"]) && $roles["owner"]) { + $hasOrganizer = true; + $this->assertEquals("John Organizer", $participant->getName()); + $sendTo = $participant->getSendTo(); + $this->assertEquals("mailto:john@example.com", $sendTo["imip"]); + } + } + $this->assertTrue($hasOrganizer); + $this->assertStringContainsString("ORGANIZER;CN=John Organizer:mailto:john@example.com", $icsString); + + // Test attendee with accepted status + $hasAcceptedAttendee = false; + foreach ($participants as $participant) { + if ($participant->getName() === "Alice Attendee") { + $hasAcceptedAttendee = true; + $this->assertEquals("accepted", $participant->getParticipationStatus()); + $this->assertTrue($participant->getExpectReply()); + $sendTo = $participant->getSendTo(); + $this->assertEquals("mailto:alice@example.com", $sendTo["imip"]); + } + } + $this->assertTrue($hasAcceptedAttendee); + $this->assertStringContainsString("ATTENDEE;CN=Alice Attendee;PARTSTAT=ACCEPTED;RSVP=TRUE", $icsString); + + // Test group attendee with MEMBER parameter + $hasGroupMember = false; + foreach ($participants as $participant) { + if ($participant->getName() === "Project Team") { + $hasGroupMember = true; + $this->assertEquals("group", $participant->getKind()); + $memberOf = $participant->getMemberOf(); + $this->assertNotEmpty($memberOf); + $this->assertIsArray($memberOf); + } + } + $this->assertTrue($hasGroupMember); + $this->assertStringContainsString("MEMBER=", $icsString); + + // Test attendee with DIR parameter + $hasDirLink = false; + foreach ($participants as $participant) { + if ($participant->getName() === "External Contact") { + $links = $participant->getLinks(); + if ($links) { + foreach ($links as $link) { + if ($link->getRel() === "describedby") { + $hasDirLink = true; + $this->assertEquals("https://contacts.example.com/external", $link->getHref()); + } + } + } + } + } + $this->assertTrue($hasDirLink); + $this->assertStringContainsString("DIR=", $icsString); + + // Test attachments + $links = $this->jsCalendarAfter->getLinks(); + $this->assertNotEmpty($links); + $hasAttachment = false; + foreach ($links as $link) { + if ($link->getRel() === "enclosure") { + $hasAttachment = true; + $this->assertEquals("https://example.com/agenda.pdf", $link->getHref()); + $this->assertEquals("application/pdf", $link->getContentType()); + $this->assertEquals("Meeting Agenda", $link->getTitle()); + $this->assertEquals(52480, $link->getSize()); + } + } + $this->assertTrue($hasAttachment); + $this->assertStringContainsString("ATTACH;FMTTYPE=application/pdf;LABEL=Meeting Agenda;SIZE=52480", $icsString); + + // Test alerts + $alerts = $this->jsCalendarAfter->getAlerts(); + $this->assertNotEmpty($alerts); + $alert = array_values($alerts)[0]; + $this->assertEquals("display", $alert->getAction()); + $trigger = $alert->getTrigger(); + $this->assertEquals("OffsetTrigger", $trigger->getType()); + $this->assertEquals("-PT15M", $trigger->getOffset()); + $this->assertEquals("start", $trigger->getRelativeTo()); + $this->assertStringContainsString("BEGIN:VALARM", $icsString); + $this->assertStringContainsString("ACTION:DISPLAY", $icsString); + $this->assertStringContainsString("TRIGGER;RELATED=START:-PT15M", $icsString); + $this->assertStringContainsString("END:VALARM", $icsString); + } } diff --git a/tests/unit/JSCalendarOXPClassTest.php b/tests/unit/JSCalendarOXPClassTest.php index ad3eeab..1eff3b5 100644 --- a/tests/unit/JSCalendarOXPClassTest.php +++ b/tests/unit/JSCalendarOXPClassTest.php @@ -1,6 +1,9 @@ setType("RecurrenceRule"); - $calendarEvent->setRecurrenceRule(array($recurrenceRule)); $calendarEvent->setRecurrenceRules(array($recurrenceRule)); - $this->assertNotNull($calendarEvent->getRecurrenceRule()); $this->assertNotNull($calendarEvent->getRecurrenceRules()); - $this->assertEquals($calendarEvent->getRecurrenceRule(), $calendarEvent->getRecurrenceRules()); - $this->assertEquals($calendarEvent->getRecurrenceRule()[0]->getType(), "RecurrenceRule"); $this->assertEquals($calendarEvent->getRecurrenceRules()[0]->getType(), "RecurrenceRule"); } @@ -44,4 +43,138 @@ public function testChangesInLocation(): void $this->assertNotEquals($location->getLinkIds(), $location->getLinks()); } -} \ No newline at end of file + + /** + * Check changes done to OpenXPort\Jmap\Calendar\CalendarEvent.php + */ + public function testCalendarEventJMAPProperties(): void + { + $event = new CalendarEvent(); + + $event->setBaseEventId("base-event-123"); + $this->assertEquals("base-event-123", $event->getBaseEventId()); + + $calendarIds = array("cal1" => true, "cal2" => true); + $event->setCalendarIds($calendarIds); + $this->assertEquals($calendarIds, $event->getCalendarIds()); + + $event->setIsDraft(true); + $this->assertTrue($event->getIsDraft()); + + $event->setMethod("request"); + $this->assertEquals("request", $event->getMethod()); + + $event->setUrl("https://example.com/events/123"); + $this->assertEquals("https://example.com/events/123", $event->getUrl()); + } + + /** + * Check changes done to OpenXPort\Jmap\Calendar\CalendarEvent.php + */ + public function testCalendarEventLocations(): void + { + $event = new CalendarEvent(); + + $event->setCoordinates("geo:40.7128,-74.0060"); + $this->assertEquals("geo:40.7128,-74.0060", $event->getCoordinates()); + + $location1 = new Location(); + $location1->setName("Conference Room A"); + $location1->setCoordinates("geo:37.386,-122.082"); + + $vLocations = array("1" => $location1); + $event->setVLocations($vLocations); + + $this->assertNotNull($event->getVLocations()); + $this->assertEquals("Conference Room A", $event->getVLocations()["1"]->getName()); + } + + /** + * Check changes done to OpenXPort\Jmap\Calendar\CalendarEvent.php + */ + public function testCalendarEventVirtualLocations(): void + { + $event = new CalendarEvent(); + + $virtualLocation = new VirtualLocation(); + $virtualLocation->setType("VirtualLocation"); + $virtualLocation->setName("Zoom Meeting"); + $virtualLocation->setUri("https://zoom.us/j/123456789"); + + $event->setVirtualLocations(array("1" => $virtualLocation)); + + $this->assertNotNull($event->getVirtualLocations()); + $this->assertEquals("Zoom Meeting", $event->getVirtualLocations()["1"]->getName()); + } + + /** + * Check changes done to OpenXPort\Jmap\Calendar\CalendarEvent.php + */ + public function testCalendarEventRecurrence(): void + { + $event = new CalendarEvent(); + + $event->setRecurrenceId("2025-03-05T09:00:00"); + $this->assertEquals("2025-03-05T09:00:00", $event->getRecurrenceId()); + + $excludedOverride = new PatchObject(); + $excludedOverride->setExcluded(true); + + $modifiedOverride = new PatchObject(); + $modifiedOverride->setTitle("Modified Title"); + + $overrides = array( + "2025-03-05T09:00:00" => $excludedOverride, + "2025-03-06T09:00:00" => $modifiedOverride, + ); + + $event->setRecurrenceOverrides($overrides); + + $this->assertNotNull($event->getRecurrenceOverrides()); + $this->assertTrue($event->getRecurrenceOverrides()["2025-03-05T09:00:00"]->getExcluded()); + } + + /** + * Check changes done to OpenXPort\Jmap\Calendar\CalendarEvent.php + */ + public function testCalendarEventScheduling(): void + { + $event = new CalendarEvent(); + + $event->setSentBy("mailto:secretary@example.com"); + $this->assertEquals("mailto:secretary@example.com", $event->getSentBy()); + + $event->setMayInviteSelf(true); + $this->assertTrue($event->getMayInviteSelf()); + + $event->setMayInviteOthers(false); + $this->assertFalse($event->getMayInviteOthers()); + + $statuses = array("2.0;Success", "2.1;Sent with success"); + $event->setRequestStatus($statuses); + + $this->assertNotNull($event->getRequestStatus()); + $this->assertEquals("2.0;Success", $event->getRequestStatus()[0]); + } + + /** + * Check changes done to OpenXPort\Jmap\Calendar\CalendarEvent.php + */ + public function testCalendarEventJsonSerialization(): void + { + $event = new CalendarEvent(); + $event->setId("event-123"); + $event->setBaseEventId("base-456"); + $event->setCalendarIds(array("cal1" => true)); + $event->setMethod("request"); + $event->setUrl("https://example.com"); + + $json = json_encode($event); + $decoded = json_decode($json, true); + + $this->assertArrayHasKey("id", $decoded); + $this->assertArrayHasKey("baseEventId", $decoded); + $this->assertArrayHasKey("method", $decoded); + $this->assertEquals("event-123", $decoded["id"]); + } +} diff --git a/tests/unit/JSContactVCardAdapterTest.php b/tests/unit/JSContactVCardAdapterTest.php index 6a2fb50..01de764 100644 --- a/tests/unit/JSContactVCardAdapterTest.php +++ b/tests/unit/JSContactVCardAdapterTest.php @@ -4,6 +4,18 @@ use OpenXPort\Adapter\JSContactVCardAdapter; use OpenXPort\Mapper\JSContactVCardMapper; +use OpenXPort\Jmap\JSContact\ContactCard; +use OpenXPort\Jmap\JSContact\Name; +use OpenXPort\Jmap\JSContact\Address; +use OpenXPort\Jmap\JSContact\EmailAddress; +use OpenXPort\Jmap\JSContact\Phone; +use OpenXPort\Jmap\JSContact\Note; +use OpenXPort\Jmap\JSContact\Organization; +use OpenXPort\Jmap\JSContact\Title; +use OpenXPort\Jmap\JSContact\Nickname; +use OpenXPort\Jmap\JSContact\Anniversary; +use OpenXPort\Jmap\JSContact\OnlineService; +use OpenXPort\Jmap\JSContact\NameComponent; use PHPUnit\Framework\TestCase; use Sabre\VObject\Reader; @@ -24,7 +36,7 @@ final class JSContactVCardAdapterTest extends TestCase /** @var array */ protected $vCardData = null; - /** @var \OpenXPort\Jmap\JSContact\Card */ + /** @var \OpenXPort\Jmap\JSContact\ContactCard */ protected $jsContactCard = null; public function setUp(): void @@ -58,7 +70,7 @@ public function testCorrectJSContactObjectTypeMapping() { $this->mapVCard(); - $this->assertInstanceOf('OpenXPort\Jmap\JSContact\Card', $this->jsContactCard); + $this->assertInstanceOf(ContactCard::class, $this->jsContactCard); } public function testCorrectAddressMapping() @@ -70,8 +82,8 @@ public function testCorrectAddressMapping() $jsContactHomeAddress = $this->jsContactCard->getAddresses()[$jsContactAddressIndices[1]]; // Assert that the JSContact addresses mapped from the vCard addresses are of the correct type - $this->assertInstanceOf('OpenXPort\Jmap\JSContact\Address', $jsContactWorkAddress); - $this->assertInstanceOf('OpenXPort\Jmap\JSContact\Address', $jsContactHomeAddress); + $this->assertInstanceOf(Address::class, $jsContactWorkAddress); + $this->assertInstanceOf(Address::class, $jsContactHomeAddress); // Assert that the @type property is properly set $this->assertEquals('Address', $jsContactWorkAddress->getAtType()); @@ -88,25 +100,41 @@ public function testCorrectAddressMapping() $jsContactHomeAddress->getContexts() ); + $workComponents = $jsContactWorkAddress->getComponents() ?: []; + $homeComponents = $jsContactHomeAddress->getComponents() ?: []; + + $this->assertNotEmpty($workComponents); + $this->assertNotEmpty($homeComponents); + // Assert correctness of the addresses' street components - $this->assertEquals("100 Waters Edge", $jsContactWorkAddress->getStreet()[0]->getValue()); - $this->assertEquals("42 Plantation St.", $jsContactHomeAddress->getStreet()[0]->getValue()); + $this->assertEquals('100 Waters Edge', $this->findAddressComponentText($workComponents, 'name')); + $this->assertEquals('42 Plantation St.', $this->findAddressComponentText($homeComponents, 'name')); // Assert correctness of the locality property - $this->assertEquals("Baytown", $jsContactWorkAddress->getLocality()); - $this->assertEquals("Baytown", $jsContactHomeAddress->getLocality()); + $this->assertEquals('Baytown', $this->findAddressComponentText($workComponents, 'locality')); + $this->assertEquals('Baytown', $this->findAddressComponentText($homeComponents, 'locality')); // Assert correctness of the region property - $this->assertEquals("LA", $jsContactWorkAddress->getRegion()); - $this->assertEquals("LA", $jsContactHomeAddress->getRegion()); + $this->assertEquals('LA', $this->findAddressComponentText($workComponents, 'region')); + $this->assertEquals('LA', $this->findAddressComponentText($homeComponents, 'region')); // Assert correctness of the country property - $this->assertEquals("United States of America", $jsContactWorkAddress->getCountry()); - $this->assertEquals("United States of America", $jsContactHomeAddress->getCountry()); + $this->assertEquals('United States of America', $this->findAddressComponentText($workComponents, 'country')); + $this->assertEquals('United States of America', $this->findAddressComponentText($homeComponents, 'country')); // Assert correctness of the postcode property - $this->assertEquals("30314", $jsContactWorkAddress->getPostcode()); - $this->assertEquals("30314", $jsContactHomeAddress->getPostcode()); + $this->assertEquals('30314', $this->findAddressComponentText($workComponents, 'postcode')); + $this->assertEquals('30314', $this->findAddressComponentText($homeComponents, 'postcode')); + } + + private function findAddressComponentText(array $components, $kind) + { + foreach ($components as $component) { + if ($component->getKind() === $kind) { + return $component->getValue(); + } + } + return null; } public function testCorrectEmailMapping() @@ -118,16 +146,16 @@ public function testCorrectEmailMapping() $jsContactWorkEmail = $this->jsContactCard->getEmails()[$jsContactEmailIndices[1]]; // Assert that the JSContact email addresses are of the correct type - $this->assertInstanceOf('OpenXPort\Jmap\JSContact\EmailAddress', $jsContactWorkEmail); - $this->assertInstanceOf('OpenXPort\Jmap\JSContact\EmailAddress', $jsContactHomeEmail); + $this->assertInstanceOf(EmailAddress::class, $jsContactWorkEmail); + $this->assertInstanceOf(EmailAddress::class, $jsContactHomeEmail); // Assert correctness of the @type property $this->assertEquals('EmailAddress', $jsContactHomeEmail->getAtType()); $this->assertEquals('EmailAddress', $jsContactWorkEmail->getAtType()); // Assert that email address values are correct - $this->assertEquals('forrestgump@example.com', $jsContactHomeEmail->getEmail()); - $this->assertEquals('forrestgump-work@example.com', $jsContactWorkEmail->getEmail()); + $this->assertEquals('forrestgump@example.com', $jsContactHomeEmail->getAddress()); + $this->assertEquals('forrestgump-work@example.com', $jsContactWorkEmail->getAddress()); // Assert that the email address contexts are correct $this->assertEquals( @@ -149,16 +177,16 @@ public function testCorrectPhoneMapping() $jsContactHomePhone = $this->jsContactCard->getPhones()[$jsContactPhoneIndices[1]]; // Assert that the JSContact phones are of the correct type - $this->assertInstanceOf('OpenXPort\Jmap\JSContact\Phone', $jsContactWorkPhone); - $this->assertInstanceOf('OpenXPort\Jmap\JSContact\Phone', $jsContactHomePhone); + $this->assertInstanceOf(Phone::class, $jsContactWorkPhone); + $this->assertInstanceOf(Phone::class, $jsContactHomePhone); // Assert correctness of the @type property $this->assertEquals('Phone', $jsContactWorkPhone->getAtType()); $this->assertEquals('Phone', $jsContactHomePhone->getAtType()); // Assert that the phone values are correct - $this->assertEquals('(111) 555-1212', $jsContactWorkPhone->getPhone()); - $this->assertEquals('(404) 555-1212', $jsContactHomePhone->getPhone()); + $this->assertEquals('(111) 555-1212', $jsContactWorkPhone->getNumber()); + $this->assertEquals('(404) 555-1212', $jsContactHomePhone->getNumber()); // Assert that the phone contexts are correct $this->assertEquals( @@ -189,7 +217,7 @@ public function testDifferentPhoneTypes() ); $this->assertEquals( - 'blabla,blabla2', + 'blabla, blabla2', $jsContactCardSpecialPhone->getLabel() ); } @@ -198,15 +226,18 @@ public function testIdEqualsUid() { $this->mapVCard(); - $this->assertEquals($this->jsContactCard->getId(), $this->jsContactCard->getUid()); + $this->assertEquals($this->jsContactCard->getUid(), $this->jsContactCard->getUid()); } public function testCorrectNotesMapping() { $this->mapVCard(); + $notes = $this->jsContactCard->getNoteObjects(); + + $firstNote = array_values($notes)[0]; // Assert that the value of the JSContact "notes" property is the one we expect - $this->assertEquals($this->jsContactCard->getNotes(), "Some text \n\n some more text"); + $this->assertEquals("Some text \n\n some more text", $firstNote->getNote()); } /* * @@ -215,9 +246,37 @@ public function testCorrectNotesMapping() */ public function testRoundtrip() { - $jsContactData = json_decode(file_get_contents(__DIR__ . '/../resources/jscontact_basic.json')); + $jsonPath = __DIR__ . '/../resources/jscontactcard_basic.json'; + $this->assertFileExists($jsonPath, 'jscontactcard_basic.json not found at: ' . $jsonPath); + $json = json_decode(file_get_contents($jsonPath)); + $this->assertNotNull($json, 'Failed to parse jscontactcard_basic.json'); + + $card = new ContactCard(); + $card->setUid($json->uid); + + $name = new Name(); + $name->setFull($json->name->full); + $card->setName($name); + + $noteObjects = []; + foreach ($json->noteObjects as $id => $noteData) { + $note = new Note(); + $note->setNote($noteData->note); + $noteObjects[$id] = $note; + } + $card->setNoteObjects($noteObjects); - $vCardData = $this->mapper->mapFromJmap(array("c1" => $jsContactData), $this->adapter); + $card->setKeywords((array) $json->keywords); + + $orgs = []; + foreach ($json->organizations as $id => $orgData) { + $org = new Organization(); + $org->setName($orgData->name); + $orgs[$id] = $org; + } + $card->setOrganizations($orgs); + + $vCardData = $this->mapper->mapFromJmap(array("c1" => $card), $this->adapter); $vCardDataReset = reset($vCardData); $this->assertNotNull($vCardDataReset["c1"]["vCard"]); @@ -225,12 +284,18 @@ public function testRoundtrip() $jsContactDataAfter = $this->mapper->mapToJmap($vCardDataReset, $this->adapter)[0]; + $notesAfter = $jsContactDataAfter->getNoteObjects(); + $this->assertNotEmpty($notesAfter); // Assert that the value of notes is still the same - $this->assertEquals($jsContactData->notes, $jsContactDataAfter->getNotes()); - $this->assertEquals((array) $jsContactData->categories, $jsContactDataAfter->getCategories()); + $this->assertEquals( + array_values((array) $json->noteObjects)[0]->note, + array_values($notesAfter)[0]->getNote() + ); + + $this->assertEquals((array) $json->keywords, $jsContactDataAfter->getKeywords()); $this->assertEquals( - $jsContactData->organizations->{"9d5ed678fe57bcca610140957afab571"}->name, + array_values($orgs)[0]->getName(), array_values($jsContactDataAfter->getOrganizations())[0]->getName() ); $this->assertNull(array_values($jsContactDataAfter->getOrganizations())[0]->getUnits()); @@ -242,27 +307,116 @@ public function testRoundtrip() */ public function testAdvancedRoundtrip() { - $jsContactData = json_decode(file_get_contents(__DIR__ . '/../resources/jscontact_advanced.json')); + $jsonPath = __DIR__ . '/../resources/jscontactcard_advanced.json'; + $this->assertFileExists($jsonPath, 'jscontactcard_advanced.json not found at: ' . $jsonPath); + $json = json_decode(file_get_contents($jsonPath)); + $this->assertNotNull($json, 'Failed to parse jscontactcard_advanced.json'); + + $card = new ContactCard(); + $card->setUid((string) $json->uid); + $card->setUpdated($json->updated); + + $kindMap = [ + 'prefix' => 'title', + 'given' => 'given', + 'surname' => 'surname', + 'middle' => 'given2', + 'suffix' => 'credential', + ]; + $nameComponents = []; + foreach ($json->name->components as $comp) { + $contactKind = isset($kindMap[$comp->type]) ? $kindMap[$comp->type] : $comp->type; + $nc = new NameComponent(); + $nc->setKind($contactKind); + $nc->setValue($comp->value); + $nameComponents[] = $nc; + } + $name = new Name(); + $name->setComponents($nameComponents); + $name->setIsOrdered(true); + $fullParts = []; + foreach ($nameComponents as $nc) { + if (in_array($nc->getKind(), ['given', 'given2', 'surname'], true)) { + $fullParts[] = $nc->getValue(); + } + } + $name->setFull(implode(' ', $fullParts)); + $card->setName($name); + + $orgs = []; + foreach ($json->organizations as $id => $orgData) { + $org = new Organization(); + $org->setName($orgData->name); + $org->setUnits((array) $orgData->units); + $orgs[$id] = $org; + } + $card->setOrganizations($orgs); + + $onlineServices = []; + foreach ($json->onlineServices as $id => $osData) { + $os = new OnlineService(); + if (isset($osData->user) && preg_match('/^[a-z][a-z0-9+\-.]*:/i', $osData->user)) { + $os->setUri($osData->user); + } elseif (isset($osData->user)) { + $os->setUser($osData->user); + } + if (isset($osData->service)) { + $os->setService($osData->service); + } + if (isset($osData->pref)) { + $os->setPref((int) $osData->pref); + } + $onlineServices[$id] = $os; + } + $card->setOnlineServices($onlineServices); + + $anniversaries = []; + foreach ($json->anniversaries as $annData) { + $ann = new Anniversary(); + $ann->setDate($annData->date); + if (isset($annData->kind)) { + $ann->setKind($annData->kind); + } else { + $ann->setKind('wedding'); + $ann->setLabel('anniversary'); + } + $anniversaries[] = $ann; + } + $card->setAnniversaries($anniversaries); - $vCardData = $this->mapper->mapFromJmap(array("c1" => $jsContactData), $this->adapter); + $vCardData = $this->mapper->mapFromJmap(array("c1" => $card), $this->adapter); $vCardDataReset = reset($vCardData); $this->assertNotNull($vCardDataReset["c1"]["vCard"]); - $this->assertStringContainsString("DERIVED", $vCardDataReset["c1"]["vCard"]); $this->assertStringContainsString("IMPP", $vCardDataReset["c1"]["vCard"]); $jsContactDataAfter = $this->mapper->mapToJmap($vCardDataReset, $this->adapter)[0]; // Assert that fullName gets derived from name (roughly) - $this->assertGreaterThan(0, strlen($jsContactDataAfter->getFullName())); + $this->assertGreaterThan(0, strlen($jsContactDataAfter->getName()->getFull())); $servicesAsArray = array_values($jsContactDataAfter->getOnlineServices()); - $this->assertEquals("xmpp:alice@example.com", $servicesAsArray[0]->getUser()); - $this->assertEquals("Skype", $servicesAsArray[1]->getService()); + $usernames = array_map( + function ($os) { + return $os->getUser(); + }, + $servicesAsArray + ); + $uris = array_map( + function ($os) { + return $os->getUri(); + }, + $servicesAsArray + ); + + $this->assertContains( + "xmpp:alice@example.com", + array_merge($usernames, $uris) + ); $this->assertEquals( - $jsContactData->organizations->{"9d5ed678fe57bcca610140957afab571"}->name, + array_values($orgs)[0]->getName(), array_values($jsContactDataAfter->getOrganizations())[0]->getName() ); $this->assertEquals( @@ -277,17 +431,26 @@ public function testAdvancedRoundtrip() */ public function testJmapRoundtrip() { - $jsContactData = json_decode(file_get_contents(__DIR__ . '/../resources/jscontact_jmap_specific.json')); + $card = new ContactCard(); + $card->setUid('c1'); + $card->setAddressBookIds(['i-am-jmap-specific']); + + $vCardData = $this->mapper->mapFromJmap( + array("c1" => $card), + $this->adapter + ); - $vCardData = $this->mapper->mapFromJmap(array("c1" => $jsContactData), $this->adapter); + $this->assertIsArray($vCardData); + $this->assertNotEmpty($vCardData); $vCardDataReset = reset($vCardData); - - $this->assertNotNull($vCardDataReset["c1"]["vCard"]); + $this->assertArrayHasKey('oxpProperties', $vCardDataReset['c1']); + $this->assertArrayHasKey('addressBookId', $vCardDataReset['c1']['oxpProperties']); $jsContactDataAfter = $this->mapper->mapToJmap($vCardDataReset, $this->adapter)[0]; - $this->assertEquals("i-am-jmap-specific", $jsContactDataAfter->getAddressBookId()); + $this->assertInstanceOf(ContactCard::class, $jsContactDataAfter); + $this->assertEquals(['i-am-jmap-specific'], $jsContactDataAfter->getAddressBookIds()); } /* * @@ -297,7 +460,7 @@ public function testCorrectMsExchangeMapping() { $this->mapVCard('/../resources/ms_exchange.vcf'); - $this->assertEquals("SomeFullName", $this->jsContactCard->getFullName()); + $this->assertEquals("SomeFullName", $this->jsContactCard->getName()->getFull()); } /* * @@ -306,20 +469,548 @@ public function testCorrectMsExchangeMapping() */ public function testMultipleRoundtrip() { - $jsContactData = json_decode(file_get_contents(__DIR__ . '/../resources/jscontact_two_cards.json')); + $jsonPath = __DIR__ . '/../resources/jscontactcard_two_cards.json'; + $this->assertFileExists($jsonPath, 'jscontactcard_two_cards.json not found at: ' . $jsonPath); + + $json = json_decode(file_get_contents($jsonPath), true); + $this->assertNotNull($json, 'Failed to parse jscontactcard_two_cards.json'); + $this->assertCount(2, $json); + + $cards = []; + foreach ($json as $cardData) { + $card = new ContactCard(); + $card->setUid((string) $cardData['uid']); + $card->setUpdated($cardData['updated']); + + $name = new Name(); + $name->setFull($cardData['name']['full']); + $card->setName($name); + + $cards[] = $card; + } $vCardData = $this->mapper->mapFromJmap( - array("c1" => $jsContactData[0], "c2" => $jsContactData[1]), + array("c1" => $cards[0], "c2" => $cards[1]), $this->adapter ); + $this->assertCount(2, $vCardData); $vCardDataReset = array("c1" => reset($vCardData[0]), "c2" => reset($vCardData[1])); $this->assertStringContainsString("Forrest Gump", $vCardDataReset["c1"]["vCard"]); + $this->assertStringContainsString("Kamala Harris", $vCardDataReset["c2"]["vCard"]); $jsContactDataAfter = $this->mapper->mapToJmap($vCardDataReset, $this->adapter); + $this->assertCount(2, $jsContactDataAfter); + + $this->assertEquals("Forrest Gump", $jsContactDataAfter[0]->getName()->getFull()); + $this->assertEquals("Kamala Harris", $jsContactDataAfter[1]->getName()->getFull()); + } + + /** + * Test full roundtrip from real-world vCard v3 file: + * vCard -> ContactCard -> vCard -> ContactCard + */ + public function testVCardV3Roundtrip() + { + $vCardPath = __DIR__ . '/../resources/test_vcard_v3.vcf'; + $this->assertFileExists($vCardPath, 'Test vCard file not found at: ' . $vCardPath); + + $originalVCard = file_get_contents($vCardPath); + $this->assertNotFalse($originalVCard, 'Failed to read vCard file'); + + $contactCards = $this->mapper->mapToJmap(array("1" => $originalVCard), $this->adapter); + $this->assertCount(1, $contactCards); + $contactCard = $contactCards[0]; + $this->assertInstanceOf(ContactCard::class, $contactCard); + + $vCardData = $this->mapper->mapFromJmap(array("c1" => $contactCard), $this->adapter); + $this->assertCount(1, $vCardData); + $this->assertArrayHasKey("c1", $vCardData[0]); + + $regeneratedVCard = $vCardData[0]["c1"]["vCard"]; + $this->assertNotNull($regeneratedVCard, 'Regenerated vCard should not be null'); + + $this->assertStringContainsString("VERSION:4.0", $regeneratedVCard); + $this->assertStringContainsString("ORG", $regeneratedVCard); + $this->assertStringContainsString("Bubba Gump Shrimp Co.", $regeneratedVCard); + $this->assertStringContainsString("TITLE", $regeneratedVCard); + $this->assertStringContainsString("Shrimp Man", $regeneratedVCard); + $this->assertStringContainsString("EMAIL", $regeneratedVCard); + $this->assertStringContainsString("TEL", $regeneratedVCard); + $this->assertStringContainsString("ADR", $regeneratedVCard); + $this->assertStringContainsString("BDAY", $regeneratedVCard); + $this->assertStringContainsString("ANNIVERSARY", $regeneratedVCard); + $this->assertStringContainsString("NOTE", $regeneratedVCard); + + $contactCards2 = $this->mapper->mapToJmap(array("c1" => $regeneratedVCard), $this->adapter); + $this->assertCount(1, $contactCards2); + $contactCard2 = $contactCards2[0]; + + $this->assertEquals( + $contactCard->getName()->getFull(), + $contactCard2->getName()->getFull() + ); + + $org1 = array_values($contactCard->getOrganizations())[0]; + $org2 = array_values($contactCard2->getOrganizations())[0]; + $this->assertEquals($org1->getName(), $org2->getName()); + $this->assertNull($org2->getUnits(), 'Single-component ORG should have no units'); + + $title1 = array_values($contactCard->getTitles())[0]; + $title2 = array_values($contactCard2->getTitles())[0]; + $this->assertEquals($title1->getName(), $title2->getName()); + + $emails1 = array_values($contactCard->getEmails()); + $emails2 = array_values($contactCard2->getEmails()); + $this->assertEquals(count($emails1), count($emails2), 'Email count should be preserved'); + + $addresses1 = array_map(function ($e) { + return $e->getAddress(); + }, $emails1); + $addresses2 = array_map(function ($e) { + return $e->getAddress(); + }, $emails2); + sort($addresses1); + sort($addresses2); + $this->assertEquals($addresses1, $addresses2, 'Email addresses should be preserved'); + + $phones1 = $contactCard->getPhones(); + $phones2 = $contactCard2->getPhones(); + $this->assertEquals(count($phones1), count($phones2), 'Phone count should be preserved'); + + $addrs1 = $contactCard->getAddresses(); + $addrs2 = $contactCard2->getAddresses(); + $this->assertEquals(count($addrs1), count($addrs2), 'Address count should be preserved'); + + $note1 = array_values($contactCard->getNoteObjects())[0]->getNote(); + $note2 = array_values($contactCard2->getNoteObjects())[0]->getNote(); + $this->assertEquals($note1, $note2, 'Note text should be preserved'); + + $anns1 = $contactCard->getAnniversaries(); + $anns2 = $contactCard2->getAnniversaries(); + $this->assertNotNull($anns2, 'Anniversaries should survive roundtrip'); + $this->assertEquals(count($anns1), count($anns2), 'Anniversary count should be preserved'); + + $bday1 = null; + $bday2 = null; + foreach ($anns1 as $a) { + if ($a->getKind() === 'birth') { + $bday1 = $a->getDate(); + } + } + foreach ($anns2 as $a) { + if ($a->getKind() === 'birth') { + $bday2 = $a->getDate(); + } + } + $this->assertEquals($bday1, $bday2, 'Birthday date should be preserved'); + + $nick1 = array_values($contactCard->getNicknames())[0]->getName(); + $nick2 = array_values($contactCard2->getNicknames())[0]->getName(); + $this->assertEquals($nick1, $nick2, 'Nickname should be preserved'); + } + + /** + * Roundtrip test based on jscontact_basic.json (Forrest Gump contact). + */ + public function testJsContactBasicRoundtrip() + { + $jsonPath = __DIR__ . '/../resources/jscontactcard_basic.json'; + $this->assertFileExists($jsonPath, 'jscontactcard_basic.json not found at: ' . $jsonPath); + $json = json_decode(file_get_contents($jsonPath)); + $this->assertNotNull($json, 'Failed to parse jscontactcard_basic.json'); + + $card = new ContactCard(); + $card->setUid($json->uid); + + $name = new Name(); + $name->setFull($json->name->full); + $card->setName($name); + + $noteObjects = []; + foreach ($json->noteObjects as $id => $noteData) { + $note = new Note(); + $note->setNote($noteData->note); + $noteObjects[$id] = $note; + } + $card->setNoteObjects($noteObjects); + + $card->setKeywords((array) $json->keywords); + + $orgs = []; + foreach ($json->organizations as $id => $orgData) { + $org = new Organization(); + $org->setName($orgData->name); + $orgs[$id] = $org; + } + $card->setOrganizations($orgs); + + $titles = []; + foreach ($json->titles as $id => $titleData) { + $title = new Title(); + $title->setName($titleData->name); + $title->setKind($titleData->kind); + $titles[$id] = $title; + } + $card->setTitles($titles); + + $emails = []; + foreach ($json->emails as $id => $emailData) { + $email = new EmailAddress(); + $email->setAddress($emailData->address); + $email->setContexts((array) $emailData->contexts); + $emails[$id] = $email; + } + $card->setEmails($emails); + + $phones = []; + foreach ($json->phones as $id => $phoneData) { + $phone = new Phone(); + $phone->setNumber($phoneData->number); + $phone->setContexts((array) $phoneData->contexts); + if (isset($phoneData->features)) { + $phone->setFeatures((array) $phoneData->features); + } + if (isset($phoneData->label)) { + $phone->setLabel($phoneData->label); + } + $phones[$id] = $phone; + } + $card->setPhones($phones); + + $nicks = []; + foreach ($json->nicknames as $id => $nickData) { + $nick = new Nickname(); + $nick->setName($nickData->name); + $nicks[$id] = $nick; + } + $card->setNicknames($nicks); + + $anniversaries = []; + foreach ($json->anniversaries as $annData) { + $ann = new Anniversary(); + $ann->setKind($annData->kind); + $ann->setDate($annData->date); + if (isset($annData->label)) { + $ann->setLabel($annData->label); + } + $anniversaries[] = $ann; + } + $card->setAnniversaries($anniversaries); + + $vCardData = $this->mapper->mapFromJmap(array("c1" => $card), $this->adapter); + + $this->assertNotNull($vCardData); + $vCardDataReset = reset($vCardData); + $this->assertNotNull($vCardDataReset["c1"]["vCard"]); + $this->assertStringContainsString("ORG", $vCardDataReset["c1"]["vCard"]); + + $vCardString = $vCardDataReset["c1"]["vCard"]; + $this->assertStringContainsString('Bubba Gump Shrimp Co.', $vCardString); + + $contactCards = $this->mapper->mapToJmap(array("c1" => $vCardString), $this->adapter); + + $this->assertCount(1, $contactCards); + $cardAfter = $contactCards[0]; + + $notesAfter = $cardAfter->getNoteObjects(); + $this->assertNotEmpty($notesAfter); + // Assert that the value of notes is still the same + $this->assertEquals( + array_values((array) $json->noteObjects)[0]->note, + array_values($notesAfter)[0]->getNote() + ); + + $keywordsAfter = $cardAfter->getKeywords(); + $this->assertNotNull($keywordsAfter); + $this->assertEquals( + (array) $json->keywords, + $keywordsAfter + ); + + $orgsAfter = $cardAfter->getOrganizations(); + $this->assertNotEmpty($orgsAfter); + $orgAfter = array_values($orgsAfter)[0]; + $this->assertEquals( + array_values($orgs)[0]->getName(), + $orgAfter->getName() + ); + + $this->assertNull($orgAfter->getUnits()); + } + + /** + * More complex mapping of JSContact -> vCard -> JSContact + */ + public function testAdvancedRoundtripExtended() + { + $jsonPath = __DIR__ . '/../resources/jscontactcard_advanced.json'; + $this->assertFileExists($jsonPath, 'jscontactcard_advanced.json not found at: ' . $jsonPath); + $json = json_decode(file_get_contents($jsonPath)); + $this->assertNotNull($json, 'Failed to parse jscontactcard_advanced.json'); + + $card = new ContactCard(); + + $card->setUid((string) $json->uid); + + $card->setUpdated($json->updated); + $kindMap = [ + 'prefix' => 'title', + 'given' => 'given', + 'surname' => 'surname', + 'middle' => 'given2', + 'suffix' => 'credential', + ]; + $nameComponents = []; + foreach ($json->name->components as $comp) { + $contactKind = isset($kindMap[$comp->type]) ? $kindMap[$comp->type] : $comp->type; + $nc = new NameComponent(); + $nc->setKind($contactKind); + $nc->setValue($comp->value); + $nameComponents[] = $nc; + } + $name = new Name(); + $name->setComponents($nameComponents); + $name->setIsOrdered(true); + $fullParts = []; + foreach ($nameComponents as $nc) { + if (in_array($nc->getKind(), ['given', 'given2', 'surname'], true)) { + $fullParts[] = $nc->getValue(); + } + } + $name->setFull(implode(' ', $fullParts)); + $card->setName($name); + + $orgs = []; + foreach ($json->organizations as $id => $orgData) { + $org = new Organization(); + $org->setName($orgData->name); + $org->setUnits((array) $orgData->units); + $orgs[$id] = $org; + } + $card->setOrganizations($orgs); + + $onlineServices = []; + foreach ($json->onlineServices as $id => $osData) { + $os = new OnlineService(); + if (isset($osData->user) && preg_match('/^[a-z][a-z0-9+\-.]*:/i', $osData->user)) { + $os->setUri($osData->user); + } elseif (isset($osData->user)) { + $os->setUser($osData->user); + } + if (isset($osData->service)) { + $os->setService($osData->service); + } + if (isset($osData->pref)) { + $os->setPref((int) $osData->pref); + } + $onlineServices[$id] = $os; + } + $card->setOnlineServices($onlineServices); + + $anniversaries = []; + foreach ($json->anniversaries as $annData) { + $ann = new Anniversary(); + $ann->setDate($annData->date); + + if (isset($annData->kind)) { + $ann->setKind($annData->kind); + } else { + $ann->setKind('wedding'); + $ann->setLabel('anniversary'); + } + + $anniversaries[] = $ann; + } + $card->setAnniversaries($anniversaries); + + $vCardData = $this->mapper->mapFromJmap(array("c1" => $card), $this->adapter); + + $this->assertNotNull($vCardData); + $vCardDataReset = reset($vCardData); + + $this->assertNotNull($vCardDataReset["c1"]["vCard"]); + $this->assertStringContainsString("IMPP", $vCardDataReset["c1"]["vCard"]); + + $vCardString = $vCardDataReset["c1"]["vCard"]; + + $this->assertStringContainsString('UID:1', $vCardString); + + $this->assertStringContainsString('John', $vCardString); + $this->assertStringContainsString('Public', $vCardString); + + $this->assertStringContainsString('Bubba Gump Shrimp Co.', $vCardString); + $this->assertStringContainsString('Cleaning department', $vCardString); + + $this->assertStringContainsString('xmpp:alice@example.com', $vCardString); + + $this->assertStringContainsString('ANNIVERSARY', $vCardString); + $this->assertStringContainsString('20230228', $vCardString); + + $contactCards = $this->mapper->mapToJmap(array("c1" => $vCardString), $this->adapter); + $this->assertCount(1, $contactCards); + $cardAfter = $contactCards[0]; + + $this->assertSame('1', $cardAfter->getUid()); + + // Assert that fullName gets derived from name + $this->assertNotNull($cardAfter->getName()); + $this->assertStringContainsString('John', $cardAfter->getName()->getFull()); + + $orgsAfter = $cardAfter->getOrganizations(); + $this->assertNotEmpty($orgsAfter); + $orgAfter = array_values($orgsAfter)[0]; + $this->assertEquals('Bubba Gump Shrimp Co.', $orgAfter->getName()); + $unitsAfter = $orgAfter->getUnits(); + $this->assertNotNull($unitsAfter, 'Org unit should survive roundtrip'); + $this->assertContains('Cleaning department', $unitsAfter); + + $onlineAfter = $cardAfter->getOnlineServices(); + $this->assertNotEmpty($onlineAfter); + $servicesAsArray = array_values($onlineAfter); + $uris = array_map( + function ($os) { + return $os->getUri() !== null ? $os->getUri() : $os->getUser(); + }, + $servicesAsArray + ); + $this->assertContains('xmpp:alice@example.com', $uris); + } + + /** + * Test JSCOMPS parameter preservation for N and ADR properties + * Uses a single vCard file with JSCOMPS on both name and address + */ + public function testJscompsPreservation() + { + $vCard = file_get_contents(__DIR__ . '/../resources/vcard_with_jscomps.vcf'); + $this->assertNotFalse($vCard, 'Failed to read vcard_with_jscomps.vcf'); + + // vCard -> JSContact + $cards = $this->mapper->mapToJmap(['test' => $vCard], $this->adapter); + $this->assertIsArray($cards); + $this->assertCount(1, $cards); + + $card = $cards[0]; + $this->assertInstanceOf(ContactCard::class, $card); + + $name = $card->getName(); + $this->assertNotNull($name); + + $nameComponents = $name->getComponents(); + $this->assertNotEmpty($nameComponents); + $this->assertCount(2, $nameComponents); + + $this->assertEquals('山田太郎', $name->getFull()); + + $addresses = $card->getAddresses(); + $this->assertNotEmpty($addresses, 'Card should have addresses'); + + $address = reset($addresses); + $this->assertInstanceOf(Address::class, $address); + + $addrComponents = $address->getComponents(); + $this->assertNotEmpty($addrComponents); + + $contexts = $address->getContexts(); + $this->assertTrue($contexts['work']); + + $exported = $this->mapper->mapFromJmap(['test' => $card], $this->adapter); + + $this->assertIsArray($exported); + $this->assertNotEmpty($exported); + + $unwrapped = array(); + foreach ($exported as $entry) { + foreach ($entry as $id => $payload) { + $unwrapped[$id] = is_array($payload) && array_key_exists('vCard', $payload) + ? $payload['vCard'] + : $payload; + } + } + + $this->assertArrayHasKey('test', $unwrapped); + $exportedVCard = $unwrapped['test']; + $unfolded = preg_replace("/\r\n[ \t]/", '', $exportedVCard); + + + // Check N property with JSCOMPS + $this->assertStringContainsString( + 'N;JSCOMPS=";0;1"', + $unfolded, + 'N property JSCOMPS should be preserved' + ); + $this->assertStringContainsString( + '山田;太郎', + $unfolded, + 'Name components should be preserved' + ); + + // Check ADR property with JSCOMPS + $this->assertStringContainsString('ADR;', $unfolded); + $this->assertStringContainsString( + 'JSCOMPS=', + $unfolded, + 'ADR property JSCOMPS should be preserved' + ); + $this->assertStringContainsString('54321', $unfolded); + $this->assertStringContainsString('Oak St', $unfolded); + $this->assertStringContainsString( + 'TYPE=work', + $unfolded, + 'Address TYPE parameter should be preserved' + ); + } + + public function testVcardAltIdLanguageRoundtripFromFile() + { + $vcfPath = __DIR__ . '/../resources/vcard_altid_language.vcf'; + $this->assertFileExists($vcfPath, 'vcard_altid_language.vcf not found at: ' . $vcfPath); + + $vcard = file_get_contents($vcfPath); + $this->assertNotFalse($vcard, 'Failed to read vCard file'); + + $adapter = new JSContactVCardAdapter(); + + // vCard -> JSContact + $adapter->setVCard($vcard); + + $card = new ContactCard(); + + $adapter->getUid($card); + $adapter->getTitles($card); + $adapter->getNotes($card); + + // check preserved params + $params = $card->getProperty('vCardParams'); + $this->assertIsArray($params); + + $this->assertArrayHasKey('TITLE', $params); + $this->assertArrayHasKey('NOTE', $params); + + // Check ALTID + LANGUAGE exist + $titleParams = array_values($params['TITLE']); + $this->assertEquals('1', $titleParams[0]['ALTID']); + $this->assertArrayHasKey('LANGUAGE', $titleParams[0]); + + // JSContact -> vCard + $adapter->reset(); + $adapter->setUid($card); + $adapter->setTitles($card); + $adapter->setNotes($card); + + $out = $adapter->getVCard(); + + $this->assertNotEmpty($out); + + $this->assertStringContainsString('ALTID=1', $out); + $this->assertStringContainsString('LANGUAGE=en', $out); + $this->assertStringContainsString('LANGUAGE=de', $out); - $this->assertEquals("Forrest Gump", $jsContactDataAfter[0]->getFullName()); - $this->assertEquals("Kamala Harris", $jsContactDataAfter[1]->getFullName()); + $this->assertStringContainsString('Chief Executive Officer', $out); + $this->assertStringContainsString('Geschäftsführer', $out); + $this->assertStringContainsString('Hello', $out); + $this->assertStringContainsString('Hallo', $out); } } diff --git a/tests/unit/NextcloudJSContactVCardAdapterTest.php b/tests/unit/NextcloudJSContactVCardAdapterTest.php index 9439540..a9c70fa 100644 --- a/tests/unit/NextcloudJSContactVCardAdapterTest.php +++ b/tests/unit/NextcloudJSContactVCardAdapterTest.php @@ -3,10 +3,10 @@ namespace OpenXPort\Test\VCard; use OpenXPort\Adapter\NextcloudJSContactVCardAdapter; -use OpenXPort\Jmap\JSContact\Audriga\Card; -use OpenXPort\Jmap\JSContact\Phone; +use OpenXPort\Jmap\JSContact\ContactCard; +use OpenXPort\Jmap\JSContact\Name; +use OpenXPort\Jmap\JSContact\OnlineService; use OpenXPort\Mapper\JSContactVCardMapper; -use OpenXPort\Test\VCard\TestUtils; use PHPUnit\Framework\TestCase; use Sabre\VObject\Reader; @@ -27,7 +27,7 @@ final class NextcloudJSContactVCardAdapterTest extends TestCase /** @var array */ protected $vCardData = null; - /** @var \OpenXPort\Jmap\JSContact\Card */ + /** @var \OpenXPort\Jmap\JSContact\ContactCard */ protected $jsContactCard = null; public function setUp(): void @@ -61,14 +61,103 @@ public function testReadNextcloudSpecific() { $this->mapVCard(); - $usernames = []; + $uris = []; + $labels = []; + foreach ($this->jsContactCard->getOnlineServices() as $id => $service) { - array_push($usernames, $service->getUser()); + $uri = $service->getUri(); + $label = $service->getLabel(); + + array_push($uris, $uri); + array_push($labels, $label); } + // Assert that for an empty IM in vCard we don't have anything mapped in JMAP $this->assertContains( "https://github.com/apache/james-project", - $usernames + $uris ); + + $this->assertContains('X-SOCIALPROFILE', $labels); + } + + public function testNextcloudSocialProfileRoundtrip() + { + $vCardString = file_get_contents(__DIR__ . '/../resources/nextcloud_socialprofile.vcf'); + $this->assertNotFalse($vCardString, 'Failed to read nextcloud_socialprofile.vcf'); + $this->assertStringContainsString('BEGIN:VCARD', $vCardString); + $this->assertStringContainsString('END:VCARD', $vCardString); + + $this->vCard = Reader::read($vCardString); + $this->vCardData = array("1" => array("vCard" => $this->vCard->serialize())); + + // Convert vCard -> JSContact + $this->adapter->setVCard(reset($this->vCardData)["vCard"]); + $card = new ContactCard(); + $this->adapter->getOnlineServices($card); + + $onlineServices = $card->getOnlineServices(); + $this->assertNotEmpty($onlineServices, "Online services should not be empty"); + + $twitterService = null; + foreach ($onlineServices as $service) { + if ($service->getService() === 'twitter') { + $twitterService = $service; + break; + } + } + $this->assertNotNull($twitterService, "Twitter service should exist"); + $this->assertEquals('johndoe', $twitterService->getUser(), "Twitter username should be 'johndoe'"); + + // Convert JSContact -> vCard (roundtrip) + $this->adapter->reset(); + $this->adapter->setOnlineServices($card); + + $socialProfiles = $this->adapter->getVCard(); + $this->assertStringContainsString('X-SOCIALPROFILE', $socialProfiles); + $this->assertStringContainsString('twitter', strtolower($socialProfiles)); + $this->assertStringContainsString('johndoe', $socialProfiles); + } + + /** + * Test JSContact to vCard conversion + * Read JSContact from JSON file and convert to vCard + */ + public function testJSContactToNextcloudVCard() + { + $jsonString = file_get_contents(__DIR__ . '/../resources/jscontactcard_nc.json'); + $this->assertNotFalse($jsonString, 'Failed to read jscontactcard_nc.json'); + + $jsonData = json_decode($jsonString, true); + $this->assertNotNull($jsonData, 'Failed to decode JSON'); + $this->assertIsArray($jsonData, 'JSON should decode to array'); + + $card = new ContactCard(); + + $card->setUid($jsonData['uid']); + + $name = new Name(); + $name->setFull($jsonData['name']['full']); + $card->setName($name); + + $services = array(); + foreach ($jsonData['onlineServices'] as $key => $serviceData) { + $service = new OnlineService(); + $service->setService($serviceData['service']); + $service->setUser($serviceData['user']); + $service->setLabel($serviceData['label']); + $services[$key] = $service; + } + $card->setOnlineServices($services); + + // JSContact -> vCard + $this->adapter->reset(); + $this->adapter->setOnlineServices($card); + + $vCardResult = $this->adapter->getVCard(); + $this->assertNotEmpty($vCardResult, "vCard should not be empty"); + $this->assertStringContainsString('X-SOCIALPROFILE', $vCardResult); + $this->assertStringContainsString('twitter', strtolower($vCardResult)); + $this->assertStringContainsString('janesmith', $vCardResult); } } diff --git a/tests/unit/OpenXPortCoreTest.php b/tests/unit/OpenXPortCoreTest.php index 5642367..7fd94c3 100644 --- a/tests/unit/OpenXPortCoreTest.php +++ b/tests/unit/OpenXPortCoreTest.php @@ -2,20 +2,20 @@ namespace OpenXPort\Test\Core; -use PHPUnit\Framework\Testcase; +use PHPUnit\Framework\TestCase; use OpenXPort\Jmap\Calendar\CalendarEvent; +use OpenXPort\Jmap\Calendar\PatchObject; /** * Deserealization of JSCalendar events from JSON files. */ -final class OpenXPortCoreTest extends Testcase +final class OpenXPortCoreTest extends TestCase { /** @var \OpenXPort\Jmap\Calendar\CalendarEvent */ protected $jsCalendar = null; public function setUp(): void { - } public function tearDown(): void @@ -87,7 +87,7 @@ public function testParseEventWithLocations() $this->assertEquals("Biggest conference room in the upper level of the main building", $currentLocation->getDescription()); $this->assertEquals("Europe/Amsterdam", $currentLocation->getTimeZone()); $this->assertEquals("geo:49.00937,8.40444", $currentLocation->getCoordinates()); - + // Check the parsing of the second location. $curentLocation = next($locations); $this->assertEquals("Location", $curentLocation->getType()); @@ -161,7 +161,7 @@ public function testParseEventWithRecurrenceRules() $this->assertEquals("NDay", $recurrenceRule->getByDay()[0]->getType()); $this->assertEquals("su", $recurrenceRule->getByDay()[0]->getDay()); $this->assertEquals("1", $recurrenceRule->getCount()); - + // Check the recurrence overrides. $this->assertEquals( array_keys($this->jsCalendar->getRecurrenceOverrides()), @@ -172,19 +172,25 @@ public function testParseEventWithRecurrenceRules() $recurrenceOverride = current($recurrenceOverrides); - $this->assertTrue($recurrenceOverride instanceof CalendarEvent); - $this->assertEquals("2023-01-23T15:00:00", $recurrenceOverride->getStart()); - $this->assertEquals("PT2H", $recurrenceOverride->getDuration()); - $this->assertEquals("Some Exam", $recurrenceOverride->getTitle()); - $this->assertEquals("Bring your own paper!", $recurrenceOverride->getDescription()); + $this->assertTrue($recurrenceOverride instanceof PatchObject); + + // Access properties through getProperties() array + $properties = $recurrenceOverride->getProperties(); + + $this->assertEquals("2023-01-23T15:00:00", $properties['start']); + $this->assertEquals("PT2H", $properties['duration']); + $this->assertEquals("Some Exam", $properties['title']); + $this->assertEquals("Bring your own paper!", $properties['description']); $recurrenceOverride = next($recurrenceOverrides); + $properties = $recurrenceOverride->getProperties(); - $this->assertEquals("Register for exam!", $recurrenceOverride->getDescription()); + $this->assertEquals("Register for exam!", $properties['description']); $recurrenceOverride = end($recurrenceOverrides); + $properties = $recurrenceOverride->getProperties(); - $this->assertTrue($recurrenceOverride->getExcluded()); + $this->assertTrue($properties['excluded']); } public function testParseEventWithVirtualLocations() @@ -196,7 +202,7 @@ public function testParseEventWithVirtualLocations() $virtualLocations = $this->jsCalendar->getVirtualLocations(); $virtualLocation = current($virtualLocations); - + $this->assertEquals("VirtualLocation", $virtualLocation->getType()); $this->assertEquals("Video Call", $virtualLocation->getName()); $this->assertEquals("Internal video call", $virtualLocation->getDescription()); @@ -207,7 +213,7 @@ public function testParseEventWithVirtualLocations() ); $virtualLocation = next($virtualLocations); - + $this->assertEquals("VirtualLocation", $virtualLocation->getType()); $this->assertEquals("Feature Keynote", $virtualLocation->getName()); $this->assertEquals("Keynote of our new Feature to be made public directly afterwards.", $virtualLocation->getDescription()); @@ -278,23 +284,31 @@ public function testParseEventWithParticipants() $this->assertEquals("none", $participant->getScheduleAgent()); $this->assertEquals("2022-12-30T12:00:00Z", $participant->getScheduleUpdated()); } - + public function testParseEventWithRelations() { - $this->jsCalendar = CalendarEvent::fromJson( - file_get_contents(__DIR__ . "/../resources/jscalendar_with_relations.json") + $jsonData = json_decode( + file_get_contents(__DIR__ . "/../resources/jscalendar_with_relations.json"), + true ); - $this->assertEquals("1234-relation-parent-OpenXPort-TestFiles", $this->jsCalendar[0]->getUid()); - $this->assertEquals("Relation", current($this->jsCalendar[0]->getRelatedTo())->getType()); - $this->assertEquals(array("parent" => true), current($this->jsCalendar[0]->getRelatedTo())->getRelation()); + $event1 = CalendarEvent::fromJson(json_decode(json_encode($jsonData[0]))); + $event2 = CalendarEvent::fromJson(json_decode(json_encode($jsonData[1]))); + $this->assertEquals("1234-relation-parent-OpenXPort-TestFiles", $event1->getUid()); - $this->assertEquals("1234-relation-child-OpenXPort-TestFiles", $this->jsCalendar[1]->getUid()); - $this->assertEquals("Relation", current($this->jsCalendar[0]->getRelatedTo())->getType()); - $this->assertEquals(array("parent" => true), current($this->jsCalendar[0]->getRelatedTo())->getRelation()); - } + $relatedTo1 = $event1->getRelatedTo(); + $firstRelation1 = array_values($relatedTo1)[0]; + $this->assertEquals("Relation", $firstRelation1->getType()); + $this->assertEquals(array("parent" => true), $firstRelation1->getRelation()); + + $this->assertEquals("1234-relation-child-OpenXPort-TestFiles", $event2->getUid()); + $relatedTo2 = $event2->getRelatedTo(); + $firstRelation2 = array_values($relatedTo2)[0]; + $this->assertEquals("Relation", $firstRelation2->getType()); + $this->assertEquals(array("child" => true), $firstRelation2->getRelation()); + } public function testParseEventWithCustomProperties() { $this->jsCalendar = CalendarEvent::fromJson( @@ -303,7 +317,7 @@ public function testParseEventWithCustomProperties() // Check that properties are read correctly. $customProperties = $this->jsCalendar->getCustomProperties(); - + $this->assertEquals("Bar", $customProperties["foo"]); $this->assertEquals("SomeObject", $customProperties["someObjects"]->{"abc-123"}->{"@type"}); $this->assertEquals("1234-someObject-OpenXPort-TestFiles", $customProperties["someObjects"]->{"abc-123"}->{"uid"}); @@ -316,7 +330,7 @@ public function testParseEventWithCustomProperties() $link = $this->jsCalendar->getLinks()["2j3j5d-6ygpgd-aljx-xup8"]; $this->assertEquals("2023-01-01T00:00:00Z", $link->getCustomProperties()["until"]); - + $relation = $this->jsCalendar->getRelatedTo()["1234-someTask-OpenXPort-TestFiles"]; $this->assertEquals(true, $relation->getCustomProperties()["requiredFinished"]); diff --git a/tests/unit/RoundcubeJSContactVCardAdapterTest.php b/tests/unit/RoundcubeJSContactVCardAdapterTest.php index 9836461..07fae4f 100644 --- a/tests/unit/RoundcubeJSContactVCardAdapterTest.php +++ b/tests/unit/RoundcubeJSContactVCardAdapterTest.php @@ -3,8 +3,14 @@ namespace OpenXPort\Test\VCard; use OpenXPort\Adapter\RoundcubeJSContactVCardAdapter; -use OpenXPort\Jmap\JSContact\Audriga\Card; +use OpenXPort\Jmap\JSContact\ContactCard; use OpenXPort\Jmap\JSContact\Phone; +use OpenXPort\Jmap\JSContact\Organization; +use OpenXPort\Jmap\JSContact\Anniversary; +use OpenXPort\Jmap\JSContact\OnlineService; +use OpenXPort\Jmap\JSContact\Address; +use OpenXPort\Jmap\JSContact\Name; +use OpenXPort\Jmap\JSContact\NameComponent; use OpenXPort\Mapper\RoundcubeJSContactVCardMapper; use OpenXPort\Test\VCard\TestUtils; use PHPUnit\Framework\TestCase; @@ -28,7 +34,7 @@ final class RoundcubeJSContactVCardAdapterTest extends TestCase /** @var array */ protected $vCardData = null; - /** @var \OpenXPort\Jmap\JSContact\Card */ + /** @var \OpenXPort\Jmap\JSContact\ContactCard */ protected $jsContactCard = null; public function setUp(): void @@ -55,22 +61,20 @@ public function tearDown(): void */ public function testCorrectRoundcubeRoundtripPhones() { - $this->jsContactCard = new Card(); + $this->jsContactCard = new ContactCard(); $pagerPhoneEntry = new Phone(); - $pagerPhoneEntry->setAtType("Phone"); - $pagerPhoneEntry->setPhone("123-pager"); + $pagerPhoneEntry->setNumber("123-pager"); $pagerPhoneEntry->setFeatures(["pager" => true]); $pagerOtherPhoneEntry = new Phone(); - $pagerOtherPhoneEntry->setAtType("Phone"); - $pagerOtherPhoneEntry->setPhone("123-other"); + $pagerOtherPhoneEntry->setNumber("123-other"); $this->jsContactCard->setPhones([ "123-pager" => $pagerPhoneEntry, "123-other" => $pagerOtherPhoneEntry ]); - $jsContactData = array("c1" => json_decode(json_encode($this->jsContactCard))); + $jsContactData = array("c1" => $this->jsContactCard); $this->vCardData = $this->mapper->mapFromJmap($jsContactData, $this->adapter); @@ -140,7 +144,560 @@ public function testConfigInvalidVCard(): void $this->assertNull($this->jsContactCard); $this->jsContactCard = $this->mapper->mapToJmap(array("c1" => $this->vCard), new RoundcubeJSContactVCardAdapter('ignoreInvalidCards')); - + $this->assertNotNull($this->jsContactCard); } + + /** + * Check that a minimal Roundcube vCard file maps to a ContactCard with the expected uid, name, email and phone. + */ + public function testMinimalRoundcubeVCardFromFileMapsToContactCard(): void + { + $vCard = file_get_contents(__DIR__ . '/../resources/rc_vcard_basic.vcf'); + $this->assertNotFalse($vCard, 'Failed to read rc_vcard_basic.vcf'); + + $mapper = new RoundcubeJSContactVCardMapper(); + $adapter = new RoundcubeJSContactVCardAdapter(); + + $result = $mapper->mapToJmap( + array('c1' => $vCard), + $adapter + ); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertInstanceOf(ContactCard::class, $result[0]); + + $card = $result[0]; + + $this->assertEquals('c1', $card->getUid()); + + $name = $card->getName(); + $this->assertNotNull($name); + + $emails = $card->getEmails(); + $this->assertIsArray($emails); + $this->assertNotEmpty($emails); + + $firstEmail = reset($emails); + $this->assertNotFalse($firstEmail); + $this->assertEquals('jane.doe@example.com', $firstEmail->getAddress()); + + $phones = $card->getPhones(); + $this->assertIsArray($phones); + $this->assertNotEmpty($phones); + + $firstPhone = reset($phones); + $this->assertNotFalse($firstPhone); + $this->assertEquals('+49-170-555-0101', $firstPhone->getNumber()); + } + + /** + * Check that a complex Roundcube vCard file correctly roundtrips through JSContact and back to vCard, + * preserving all fields including UTF-8 characters, phones, addresses, online services and relations. + */ + public function testComplexRoundcubeVCardRoundtripFromFile(): void + { + $vCard = file_get_contents(__DIR__ . '/../resources/rc_vcard_advanced.vcf'); + $this->assertNotFalse($vCard, 'Failed to read rc_vcard_advanced.vcf'); + $this->assertStringContainsString('BEGIN:VCARD', $vCard); + $this->assertStringContainsString('END:VCARD', $vCard); + + // vCard -> JSContact + $cards = $this->mapper->mapToJmap( + array('rc-complex-001' => $vCard), + $this->adapter + ); + + $this->assertIsArray($cards); + $this->assertCount(1, $cards); + $this->assertInstanceOf(ContactCard::class, $cards[0]); + + $card = $cards[0]; + + $this->assertEquals('rc-complex-001', $card->getUid()); + $this->assertEquals( + '-//Roundcube Webmail//NONSGML Roundcube Contact//EN', + $card->getProdId() + ); + $this->assertEquals('2026-03-16T12:00:00Z', $card->getUpdated()); + + // Maiden name + $this->assertEquals( + 'Öster', + $card->getProperty('audriga.eu/roundcube:maidenName') + ); + + // Name + $name = $card->getName(); + $this->assertNotNull($name); + $this->assertEquals('Dr. Jörg Åström', $name->getFull()); + + $components = $name->getComponents(); + $this->assertCount(3, $components); + $this->assertEquals('title', $components[0]->getKind()); + $this->assertEquals('Dr.', $components[0]->getValue()); + $this->assertEquals('given', $components[1]->getKind()); + $this->assertEquals('Jörg', $components[1]->getValue()); + $this->assertEquals('surname', $components[2]->getKind()); + $this->assertEquals('Åström', $components[2]->getValue()); + + // Nickname + $nicknames = $card->getNicknames(); + $this->assertCount(1, $nicknames); + $nickname = reset($nicknames); + $this->assertNotFalse($nickname); + $this->assertEquals('Jörgi', $nickname->getName()); + + // Organization + $organizations = $card->getOrganizations(); + $this->assertCount(1, $organizations); + $organization = reset($organizations); + $this->assertNotFalse($organization); + $this->assertEquals('Äcme GmbH', $organization->getName()); + $this->assertEquals( + array('Forschung und Entwicklung', 'Forschung'), + $organization->getUnits() + ); + + // Title + $titles = $card->getTitles(); + $this->assertCount(1, $titles); + $title = reset($titles); + $this->assertNotFalse($title); + $this->assertEquals('Leitender Entwickler', $title->getName()); + $this->assertEquals('title', $title->getKind()); + + // SpeakToAs / gender + $speakToAs = $card->getSpeakToAs(); + $this->assertNotNull($speakToAs); + $this->assertEquals('male', $speakToAs->getGrammaticalGender()); + + // Emails + $emails = $card->getEmails(); + $this->assertCount(2, $emails); + + $emailValues = array(); + foreach ($emails as $email) { + $emailValues[] = $email->getAddress(); + } + $this->assertContains('joerg.aestroem@example.com', $emailValues); + $this->assertContains('joerg.astrom@work.example', $emailValues); + + // Phones + $phones = $card->getPhones(); + $this->assertCount(3, $phones); + + $phoneMap = array(); + foreach ($phones as $phone) { + $phoneMap[$phone->getNumber()] = $phone; + } + + $this->assertArrayHasKey('+49-170-555-0101', $phoneMap); + $this->assertArrayHasKey('+49-30-555-0102', $phoneMap); + $this->assertArrayHasKey('123-pager', $phoneMap); + + $mobileFeatures = $phoneMap['+49-170-555-0101']->getFeatures(); + $this->assertTrue($mobileFeatures['mobile']); + $this->assertTrue($mobileFeatures['voice']); + + $homeContexts = $phoneMap['+49-30-555-0102']->getContexts(); + $homeFeatures = $phoneMap['+49-30-555-0102']->getFeatures(); + $this->assertTrue($homeContexts['private']); + $this->assertTrue($homeFeatures['voice']); + + $pagerFeatures = $phoneMap['123-pager']->getFeatures(); + $this->assertTrue($pagerFeatures['pager']); + + // Address + $addresses = $card->getAddresses(); + $this->assertCount(1, $addresses); + + $address = reset($addresses); + $this->assertNotFalse($address); + $this->assertInstanceOf(Address::class, $address); + + $addressComponents = $address->getComponents(); + $this->assertCount(4, $addressComponents); + + $this->assertEquals('name', $addressComponents[0]->getKind()); + $this->assertEquals('Münzstraße 12', $addressComponents[0]->getValue()); + $this->assertEquals('locality', $addressComponents[1]->getKind()); + $this->assertEquals('Berlin', $addressComponents[1]->getValue()); + $this->assertEquals('postcode', $addressComponents[2]->getKind()); + $this->assertEquals('10178', $addressComponents[2]->getValue()); + $this->assertEquals('country', $addressComponents[3]->getKind()); + $this->assertEquals('Germany', $addressComponents[3]->getValue()); + + $addressContexts = $address->getContexts(); + $this->assertTrue($addressContexts['private']); + + // Notes + $notes = $card->getNoteObjects(); + $this->assertCount(1, $notes); + $note = reset($notes); + $this->assertNotFalse($note); + $this->assertEquals( + 'Roundcube test contact with UTF-8 characters: ä ö ü ß é Å.', + $note->getNote() + ); + + // Online services + $online = $card->getOnlineServices(); + $this->assertCount(4, $online); + + $onlineByLabelOrUri = array(); + foreach ($online as $entry) { + $key = $entry->getLabel() ?: $entry->getUri(); + $onlineByLabelOrUri[$key] = $entry; + } + + $this->assertArrayHasKey('https://example.com/~joerg', $onlineByLabelOrUri); + $this->assertArrayHasKey('X-AIM', $onlineByLabelOrUri); + $this->assertArrayHasKey('X-JABBER', $onlineByLabelOrUri); + $this->assertArrayHasKey('X-SKYPE-USERNAME', $onlineByLabelOrUri); + + $this->assertEquals('joergaim', $onlineByLabelOrUri['X-AIM']->getUri()); + $this->assertEquals('aim', $onlineByLabelOrUri['X-AIM']->getService()); + $this->assertEquals('joerg@jabber.example', $onlineByLabelOrUri['X-JABBER']->getUri()); + $this->assertEquals('jabber', $onlineByLabelOrUri['X-JABBER']->getService()); + $this->assertEquals('joerg.astrom.skype', $onlineByLabelOrUri['X-SKYPE-USERNAME']->getUser()); + $this->assertEquals('skype', $onlineByLabelOrUri['X-SKYPE-USERNAME']->getService()); + + // Anniversaries + $anniversaries = $card->getAnniversaries(); + $this->assertCount(1, $anniversaries); + $anniversary = reset($anniversaries); + $this->assertNotFalse($anniversary); + $this->assertEquals('birth', $anniversary->getKind()); + $this->assertEquals('1988-04-12', $anniversary->getDate()); + + // Relations + $relatedTo = $card->getRelatedTo(); + $this->assertCount(3, $relatedTo); + $this->assertArrayHasKey('Renée Manager', $relatedTo); + $this->assertArrayHasKey('Björk Assistant', $relatedTo); + $this->assertArrayHasKey('Zoë Åström', $relatedTo); + $this->assertTrue($relatedTo['Renée Manager']->getRelation()['manager']); + $this->assertTrue($relatedTo['Björk Assistant']->getRelation()['assistant']); + $this->assertTrue($relatedTo['Zoë Åström']->getRelation()['spouse']); + + // JSContact -> vCard + $exported = $this->mapper->mapFromJmap( + array('rc-complex-001' => $card), + $this->adapter + ); + + $this->assertIsArray($exported); + $this->assertNotEmpty($exported); + + $unwrapped = array(); + foreach ($exported as $entry) { + foreach ($entry as $id => $payload) { + $unwrapped[$id] = is_array($payload) && array_key_exists('vCard', $payload) + ? $payload['vCard'] + : $payload; + } + } + + $this->assertArrayHasKey('rc-complex-001', $unwrapped); + $this->assertIsString($unwrapped['rc-complex-001']); + + $exportedVCard = $unwrapped['rc-complex-001']; + $unfoldedVCard = preg_replace("/\r\n[ \t]/", '', $exportedVCard); + $this->assertNotNull($unfoldedVCard); + + $this->assertStringContainsString('BEGIN:VCARD', $unfoldedVCard); + $this->assertStringContainsString('END:VCARD', $unfoldedVCard); + $this->assertStringContainsString('FN:Dr. Jörg Åström', $unfoldedVCard); + $this->assertStringContainsString('N:Åström;Jörg;;Dr.;', $unfoldedVCard); + $this->assertStringContainsString('NICKNAME:Jörgi', $unfoldedVCard); + $this->assertStringContainsString('joerg.aestroem@example.com', $unfoldedVCard); + $this->assertStringContainsString('joerg.astrom@work.example', $unfoldedVCard); + $this->assertStringContainsString('+49-170-555-0101', $unfoldedVCard); + $this->assertStringContainsString('+49-30-555-0102', $unfoldedVCard); + $this->assertStringContainsString('123-pager', $unfoldedVCard); + $this->assertStringContainsString('Äcme GmbH', $unfoldedVCard); + $this->assertStringContainsString('Leitender Entwickler', $unfoldedVCard); + $this->assertStringContainsString('https://example.com/~joerg', $unfoldedVCard); + $this->assertStringContainsString('X-MAIDENNAME:Öster', $unfoldedVCard); + $this->assertTrue( + str_contains($unfoldedVCard, '19880412') || + str_contains($unfoldedVCard, '1988-04-12') + ); + $this->assertStringContainsString('ADR;', $unfoldedVCard); + $this->assertStringContainsString('Münzstraße 12', $unfoldedVCard); + $this->assertStringContainsString('Berlin', $unfoldedVCard); + $this->assertStringContainsString('10178', $unfoldedVCard); + $this->assertStringContainsString('Germany', $unfoldedVCard); + $this->assertStringContainsString('X-GENDER:male', $unfoldedVCard); + $this->assertStringContainsString('X-AIM:joergaim', $unfoldedVCard); + $this->assertStringContainsString('X-JABBER:joerg@jabber.example', $unfoldedVCard); + $this->assertStringContainsString('X-SKYPE-USERNAME:joerg.astrom.skype', $unfoldedVCard); + $this->assertStringContainsString('X-MANAGER:Renée Manager', $unfoldedVCard); + $this->assertStringContainsString('X-ASSISTANT:Björk Assistant', $unfoldedVCard); + $this->assertStringContainsString('X-SPOUSE:Zoë Åström', $unfoldedVCard); + $this->assertStringContainsString('X-DEPARTMENT:', $unfoldedVCard); + + $roundtripped = $this->mapper->mapToJmap($unwrapped, $this->adapter); + + $this->assertIsArray($roundtripped); + $this->assertCount(1, $roundtripped); + $this->assertInstanceOf(ContactCard::class, $roundtripped[0]); + + $rtCard = $roundtripped[0]; + + $this->assertEquals('rc-complex-001', $rtCard->getUid()); + $this->assertEquals($card->getProdId(), $rtCard->getProdId()); + $this->assertEquals($card->getUpdated(), $rtCard->getUpdated()); + $this->assertEquals( + 'Öster', + $rtCard->getProperty('audriga.eu/roundcube:maidenName') + ); + + // Roundtrip checks + $rtEmails = $rtCard->getEmails(); + $this->assertCount(2, $rtEmails); + + $rtEmailValues = array(); + foreach ($rtEmails as $email) { + $rtEmailValues[] = $email->getAddress(); + } + $this->assertContains('joerg.aestroem@example.com', $rtEmailValues); + $this->assertContains('joerg.astrom@work.example', $rtEmailValues); + + $rtPhones = $rtCard->getPhones(); + $this->assertCount(3, $rtPhones); + + $rtPhoneValues = array(); + foreach ($rtPhones as $phone) { + $rtPhoneValues[] = $phone->getNumber(); + } + $this->assertContains('+49-170-555-0101', $rtPhoneValues); + $this->assertContains('+49-30-555-0102', $rtPhoneValues); + $this->assertContains('123-pager', $rtPhoneValues); + + $rtPagerPhone = null; + foreach ($rtPhones as $phone) { + if ($phone->getNumber() === '123-pager') { + $rtPagerPhone = $phone; + break; + } + } + $this->assertNotNull($rtPagerPhone); + $rtFeatures = $rtPagerPhone->getFeatures(); + $this->assertTrue($rtFeatures['pager']); + + $rtAddresses = $rtCard->getAddresses(); + $this->assertCount(1, $rtAddresses); + + $rtOrganizations = $rtCard->getOrganizations(); + $this->assertNotEmpty($rtOrganizations); + + $rtTitles = $rtCard->getTitles(); + $this->assertNotEmpty($rtTitles); + + $rtNotes = $rtCard->getNoteObjects(); + $this->assertCount(1, $rtNotes); + $rtNote = reset($rtNotes); + $this->assertNotFalse($rtNote); + $this->assertEquals( + 'Roundcube test contact with UTF-8 characters: ä ö ü ß é Å.', + $rtNote->getNote() + ); + + $rtOnline = $rtCard->getOnlineServices(); + $this->assertNotEmpty($rtOnline); + + $rtAnniversaries = $rtCard->getAnniversaries(); + $this->assertNotEmpty($rtAnniversaries); + + $rtRelatedTo = $rtCard->getRelatedTo(); + $this->assertNotEmpty($rtRelatedTo); + + $this->assertEquals( + array_values($card->getEmails()), + array_values($rtCard->getEmails()) + ); + $this->assertEquals( + array_values($card->getPhones()), + array_values($rtCard->getPhones()) + ); + $this->assertEquals( + array_values($card->getOrganizations()), + array_values($rtCard->getOrganizations()) + ); + $this->assertEquals( + array_values($card->getTitles()), + array_values($rtCard->getTitles()) + ); + $this->assertEquals( + array_values($card->getAnniversaries()), + array_values($rtCard->getAnniversaries()) + ); + $this->assertEquals( + array_values($card->getNoteObjects()), + array_values($rtCard->getNoteObjects()) + ); + $this->assertEquals( + array_values($card->getRelatedTo()), + array_values($rtCard->getRelatedTo()) + ); + } + + /** + * Check that a JSContact JSON file correctly roundtrips to a Roundcube vCard and back, + * preserving name, organization, anniversary and online service fields. + */ + public function testJsContactJsonFileRoundtripToRoundcubeVCard(): void + { + $json = file_get_contents(__DIR__ . '/../resources/jscontactcard_advanced.json'); + $this->assertNotFalse($json, 'Failed to read jscontactcard_advanced.json'); + + $data = json_decode($json, true); + $this->assertIsArray($data, 'Failed to decode jscontactcard_advanced.json'); + + $card = new ContactCard(); + + // uid / updated + $card->setUid($data['uid']); + $card->setUpdated($data['updated']); + + // Name + $name = new Name(); + $components = array(); + + foreach ($data['name']['components'] as $componentData) { + $component = new NameComponent(); + + $type = $componentData['type']; + $value = $componentData['value']; + + $kindMap = array( + 'prefix' => 'title', + 'given' => 'given', + 'surname' => 'surname', + 'middle' => 'given2', + 'suffix' => 'credential', + ); + + $component->setKind($kindMap[$type]); + $component->setValue($value); + + $components[] = $component; + } + + $name->setComponents($components); + $name->setIsOrdered(true); + $name->setFull('Mr. John Quinlan Public Esq.'); + $card->setName($name); + + // Online services + $services = array(); + foreach ($data['onlineServices'] as $id => $serviceData) { + $service = new OnlineService(); + $service->setService(strtolower((string) $serviceData['service'])); + + if ($serviceData['type'] === 'impp') { + $service->setUri((string) $serviceData['user']); + } else { + $service->setUser((string) $serviceData['user']); + } + + $service->setPref((int) $serviceData['pref']); + $services[$id] = $service; + } + $card->setOnlineServices($services); + + // Organizations + $organizations = array(); + foreach ($data['organizations'] as $id => $orgData) { + $organization = new Organization(); + $organization->setName($orgData['name']); + $organization->setUnits($orgData['units']); + $organizations[$id] = $organization; + } + $card->setOrganizations($organizations); + + // Anniversaries + $anniversaries = array(); + foreach ($data['anniversaries'] as $id => $annData) { + $anniversary = new Anniversary(); + $anniversary->setKind('wedding'); + $anniversary->setLabel('anniversary'); + $anniversary->setDate($annData['date']); + $anniversaries[] = $anniversary; + } + $card->setAnniversaries($anniversaries); + + // JSContact -> Roundcube vCard + $exported = $this->mapper->mapFromJmap( + array('c1' => $card), + $this->adapter + ); + + $this->assertIsArray($exported); + $this->assertNotEmpty($exported); + + $unwrapped = array(); + foreach ($exported as $entry) { + foreach ($entry as $id => $payload) { + $unwrapped[$id] = is_array($payload) && array_key_exists('vCard', $payload) + ? $payload['vCard'] + : $payload; + } + } + + $this->assertArrayHasKey('c1', $unwrapped); + $this->assertIsString($unwrapped['c1']); + + $exportedVCard = $unwrapped['c1']; + $unfoldedVCard = preg_replace("/\r\n[ \t]/", '', $exportedVCard); + $this->assertNotNull($unfoldedVCard); + + $this->assertStringContainsString('BEGIN:VCARD', $unfoldedVCard); + $this->assertStringContainsString('END:VCARD', $unfoldedVCard); + $this->assertStringContainsString('UID:1', $unfoldedVCard); + $this->assertStringContainsString('REV:20080424T195243Z', $unfoldedVCard); + $this->assertStringContainsString('FN:Mr. John Quinlan Public Esq.', $unfoldedVCard); + $this->assertStringContainsString('Public;John;Quinlan;Mr.;Esq.', $unfoldedVCard); + $this->assertStringContainsString('ORG:Bubba Gump Shrimp Co.', $unfoldedVCard); + $this->assertStringContainsString('X-DEPARTMENT:Cleaning department', $unfoldedVCard); + $this->assertTrue( + str_contains($unfoldedVCard, 'ANNIVERSARY;VALUE=date:20230228') + || str_contains($unfoldedVCard, 'ANNIVERSARY:20230228') + ); + + $this->assertStringContainsString('alice@example.com', $unfoldedVCard); + $this->assertStringContainsString('PupkinV', $unfoldedVCard); + + // vCard -> JSContact + $roundtripped = $this->mapper->mapToJmap($unwrapped, $this->adapter); + + $this->assertIsArray($roundtripped); + $this->assertCount(1, $roundtripped); + $this->assertInstanceOf(ContactCard::class, $roundtripped[0]); + + $rtCard = $roundtripped[0]; + + $this->assertEquals('1', $rtCard->getUid()); + $this->assertEquals('2008-04-24T19:52:43Z', $rtCard->getUpdated()); + + $rtName = $rtCard->getName(); + $this->assertNotNull($rtName); + $this->assertEquals('Mr. John Quinlan Public Esq.', $rtName->getFull()); + + $rtOrganizations = $rtCard->getOrganizations() ?: array(); + $this->assertCount(1, $rtOrganizations); + $rtOrg = reset($rtOrganizations); + $this->assertNotFalse($rtOrg); + $this->assertEquals('Bubba Gump Shrimp Co.', $rtOrg->getName()); + $this->assertEquals(array('Cleaning department'), $rtOrg->getUnits()); + + $rtAnniversaries = $rtCard->getAnniversaries() ?: array(); + $this->assertNotEmpty($rtAnniversaries); + + $rtOnline = $rtCard->getOnlineServices() ?: array(); + $this->assertNotEmpty($rtOnline); + } } diff --git a/tests/unit/VObjectTest.php b/tests/unit/VObjectTest.php index 104943a..01b061d 100644 --- a/tests/unit/VObjectTest.php +++ b/tests/unit/VObjectTest.php @@ -10,7 +10,7 @@ */ final class VObjectTest extends TestCase { - public function testReadVcard(): void + public function testReadVcard() { // Read the vCard from the file test_vcard.vcf $vcard = VObject\Reader::read( @@ -31,7 +31,7 @@ public function testReadIcalendar(): void $this->assertEquals('Just a Test', $icalendar->VEVENT->SUMMARY); } - public function testReadHordeVcard(): void + public function testReadHordeVcard() { // Read the vCard from the file horde.vcf $hordeVcard = VObject\Reader::read(